From ea7725e230f65e84d1a3474390b95ea2ef8fca92 Mon Sep 17 00:00:00 2001 From: sanex3339 Date: Thu, 18 Jun 2026 22:28:49 +0400 Subject: [PATCH 1/2] Setup template for allowed hosts configuration --- README.md | 17 ++++++++ config/dev-connect-src.ts | 63 ++++++++++++++++++++++++++++ data_app.yaml | 7 ++++ package.json | 3 +- vite.config.ts | 86 ++++++++++++++++++++++++++------------- 5 files changed, 146 insertions(+), 30 deletions(-) create mode 100644 config/dev-connect-src.ts diff --git a/README.md b/README.md index e38e17d..0c76057 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,20 @@ Admin → Embedding → Embedded analytics SDK → CORS. `src/App.tsx` and anything you add under `src/` is shared between dev and prod. The two modes only diverge at the entry layer. + +## Calling external APIs (`allowed_hosts`) + +A data app runs sandboxed: by default it **can't** `fetch`/XHR anything. To let +it reach an external API, list the origins in `data_app.yml` under +`allowed_hosts` (supports a `*.` subdomain wildcard): + +```yaml +allowed_hosts: + - https://api.example.com + - https://*.internal.acme.com +``` + +The same allowlist is enforced in both places: `npm run dev` applies it via the +dev server's CSP, and Metabase applies it via the iframe CSP + the membrane +sandbox. The Metabase instance itself is reached through the SDK (not listed +here). A call to any other host fails in dev exactly as it will in production. diff --git a/config/dev-connect-src.ts b/config/dev-connect-src.ts new file mode 100644 index 0000000..43ad233 --- /dev/null +++ b/config/dev-connect-src.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import { parse as parseYaml } from "yaml"; + +/** + * Read the `allowed_hosts` list from this app's `data_app.yml`/`.yaml`. Returns + * `[]` when the file is missing/unparseable or the key is absent; non-string + * entries are dropped. + */ +export function readAllowedHosts(yamlPath: string): string[] { + let parsed: unknown; + try { + parsed = parseYaml(fs.readFileSync(yamlPath, "utf8")); + } catch { + return []; + } + + const hosts = + typeof parsed === "object" && parsed !== null + ? (parsed as { allowed_hosts?: unknown }).allowed_hosts + : undefined; + + return Array.isArray(hosts) + ? hosts.filter((host): host is string => typeof host === "string") + : []; +} + +/** + * Dev-server CSP `connect-src` that mirrors what Metabase emits for a data app + * in production: the app may reach its declared `allowed_hosts` (plus the + * Metabase instance, for the SDK's own calls) and the Vite dev server / HMR + * websocket — nothing else. So a `fetch`/XHR a production data app couldn't + * make is blocked by the browser in `npm run dev` too, instead of silently + * working locally and failing once sandboxed in Metabase. + */ +function toOrigin(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + return new URL(url).origin; + } catch { + return url; + } +} + +export function buildDevConnectSrcCsp( + allowedHosts: string[], + metabaseUrl: string | undefined, +): string { + // The Metabase instance origin MUST be allowed — the SDK calls it (and in dev + // it's a different origin than the dev server, so `'self'` doesn't cover it). + const instanceOrigin = toOrigin(metabaseUrl); + const sources = [ + "'self'", + "ws://localhost:*", + "wss://localhost:*", + "ws://127.0.0.1:*", + "wss://127.0.0.1:*", + ...(instanceOrigin ? [instanceOrigin] : []), + ...allowedHosts, + ]; + return `connect-src ${sources.join(" ")}`; +} diff --git a/data_app.yaml b/data_app.yaml index c5495aa..fe536ff 100644 --- a/data_app.yaml +++ b/data_app.yaml @@ -1,3 +1,10 @@ name: Data App Template slug: data-app-template path: ./dist/index.js + +# Origins this app may call with fetch/XHR. Everything else is blocked — both in +# `npm run dev` (via the dev server's CSP) and inside Metabase (the sandbox). +# The Metabase instance itself is reached through the SDK, not listed here. +# allowed_hosts: +# - https://api.example.com +# - https://*.internal.acme.com diff --git a/package.json b/package.json index d181eaa..989f5ec 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^6.0.2", "typescript": "^6.0.3", - "vite": "^8.0.16" + "vite": "^8.0.16", + "yaml": "^2.9.0" } } diff --git a/vite.config.ts b/vite.config.ts index 817905b..fc2af9e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,39 +1,67 @@ +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; -import { type LibraryFormats, defineConfig } from "vite"; +import { type LibraryFormats, defineConfig, loadEnv } from "vite"; +import { + buildDevConnectSrcCsp, + readAllowedHosts, +} from "./config/dev-connect-src"; import { findEnvRoot } from "./config/env-root"; const appRoot = path.dirname(fileURLToPath(import.meta.url)); -export default defineConfig(() => ({ - envDir: findEnvRoot(appRoot), - plugins: [react()], - build: { - outDir: "dist", - emptyOutDir: true, - lib: { - entry: "src/index.tsx", - formats: ["iife"] satisfies LibraryFormats[], - fileName: () => "index.js", - name: "__dataAppFactory__", - }, - rollupOptions: { - external: [ - "react", - "react/jsx-runtime", - "@metabase/embedding-sdk-react", - "@metabase/embedding-sdk-react/data-app", - ], - output: { - globals: { - react: "React", - "react/jsx-runtime": "__react_jsx_runtime__", - "@metabase/embedding-sdk-react": "__metabase_sdk__", - "@metabase/embedding-sdk-react/data-app": "__metabase_data_app__", +export default defineConfig(({ mode }) => { + const envDir = findEnvRoot(appRoot); + const env = loadEnv(mode, envDir, ""); + + const manifestPath = [ + path.join(appRoot, "data_app.yaml"), + path.join(appRoot, "data_app.yml"), + ].find((candidate) => fs.existsSync(candidate)); + const allowedHosts = manifestPath ? readAllowedHosts(manifestPath) : []; + + return { + envDir, + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true, + lib: { + entry: "src/index.tsx", + formats: ["iife"] satisfies LibraryFormats[], + fileName: () => "index.js", + name: "__dataAppFactory__", + }, + rollupOptions: { + external: [ + "react", + "react/jsx-runtime", + "@metabase/embedding-sdk-react", + "@metabase/embedding-sdk-react/data-app", + ], + output: { + globals: { + react: "React", + "react/jsx-runtime": "__react_jsx_runtime__", + "@metabase/embedding-sdk-react": "__metabase_sdk__", + "@metabase/embedding-sdk-react/data-app": "__metabase_data_app__", + }, }, }, }, - }, - server: { port: 5174, host: "localhost" }, -})); + server: { + port: 5174, + host: "localhost", + // Enforce the app's `allowed_hosts` in dev the same way Metabase does in + // production (via CSP `connect-src`), so a fetch/XHR a sandboxed data app + // couldn't make fails here too instead of only in Metabase. + headers: { + "Content-Security-Policy": buildDevConnectSrcCsp( + allowedHosts, + env.VITE_MB_URL, + ), + }, + }, + }; +}); From 6496c4ecc49e75bb83a54398aeb7db05435425a6 Mon Sep 17 00:00:00 2001 From: sanex3339 Date: Fri, 19 Jun 2026 17:58:34 +0400 Subject: [PATCH 2/2] Point allowed_hosts docs at data_app.yaml The template ships data_app.yaml and vite.config.ts prefers .yaml over .yml, so a user following the README to edit data_app.yml would get an empty allowlist in dev. Reference the file that actually ships. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c76057..2b47f5d 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ and prod. The two modes only diverge at the entry layer. ## Calling external APIs (`allowed_hosts`) A data app runs sandboxed: by default it **can't** `fetch`/XHR anything. To let -it reach an external API, list the origins in `data_app.yml` under +it reach an external API, list the origins in `data_app.yaml` under `allowed_hosts` (supports a `*.` subdomain wildcard): ```yaml