diff --git a/apps/dev-playground/client/src/routes/files.route.tsx b/apps/dev-playground/client/src/routes/files.route.tsx index 4f4d8b87..fea7ee7e 100644 --- a/apps/dev-playground/client/src/routes/files.route.tsx +++ b/apps/dev-playground/client/src/routes/files.route.tsx @@ -5,6 +5,7 @@ import { FileBreadcrumb, FilePreviewPanel, NewFolderInput, + usePluginClientConfig, } from "@databricks/appkit-ui/react"; import { createFileRoute, retainSearchParams } from "@tanstack/react-router"; import { FolderPlus, Loader2, Upload } from "lucide-react"; @@ -36,8 +37,15 @@ export const Route = createFileRoute("/files")({ }, }); +interface FilesClientConfig { + volumes?: string[]; +} + +const EMPTY_VOLUMES: readonly string[] = Object.freeze([]); + function FilesRoute() { - const [volumes, setVolumes] = useState([]); + const { volumes = EMPTY_VOLUMES } = + usePluginClientConfig("files"); const [volumeKey, setVolumeKey] = useState( () => localStorage.getItem("appkit:files:volumeKey") ?? "", ); @@ -139,21 +147,14 @@ function FilesRoute() { ); useEffect(() => { - fetch("/api/files/volumes") - .then((res) => res.json()) - .then((data: { volumes: string[] }) => { - const list = data.volumes ?? []; - setVolumes(list); - if (!volumeKey || !list.includes(volumeKey)) { - const first = list[0]; - if (first) { - setVolumeKey(first); - localStorage.setItem("appkit:files:volumeKey", first); - } - } - }) - .catch(() => {}); - }, [volumeKey]); + if (!volumeKey || !volumes.includes(volumeKey)) { + const first = volumes[0]; + if (first) { + setVolumeKey(first); + localStorage.setItem("appkit:files:volumeKey", first); + } + } + }, [volumeKey, volumes]); // Load root directory when volume key is set useEffect(() => { diff --git a/apps/dev-playground/package.json b/apps/dev-playground/package.json index 0b4e4af8..596c1862 100644 --- a/apps/dev-playground/package.json +++ b/apps/dev-playground/package.json @@ -6,7 +6,7 @@ "start": "node build/index.mjs", "start:local": "NODE_ENV=production node --env-file=./server/.env build/index.mjs", "dev": "NODE_ENV=development tsx watch server/index.ts", - "dev:inspect": "tsx --inspect --tsconfig ./tsconfig.json ./server", + "dev:inspect": "NODE_ENV=development tsx --inspect --tsconfig ./tsconfig.json ./server", "build": "npm run build:app", "build:app": "tsdown --out-dir build server/index.ts && cd client && npm run build", "build:server": "tsdown --out-dir build server/index.ts", diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 8ce8d591..12a9c7bc 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -244,6 +244,69 @@ AuthenticationError if user token is not available in request headers (productio *** +### clientConfig() + +```ts +clientConfig(): Record; +``` + +Returns startup config to expose to the client. +Override this to surface server-side values that are safe to publish to the +frontend, such as feature flags, resource IDs, or other app boot settings. + +This runs once when the server starts, so it should not depend on +request-scoped or user-specific state. + +String values that match non-public environment variables are redacted +unless you intentionally expose them via a matching `PUBLIC_APPKIT_` env var. + +Values must be JSON-serializable plain data (no functions, Dates, classes, +Maps, Sets, BigInts, or circular references). +By default returns an empty object (plugin contributes nothing to client config). + +On the client, read the config with the `usePluginClientConfig` hook +(React) or the `getPluginClientConfig` function (vanilla JS), both +from `@databricks/appkit-ui`. + +#### Returns + +`Record`\<`string`, `unknown`\> + +#### Example + +```ts +// Server — plugin definition +class MyPlugin extends Plugin { + clientConfig() { + return { + warehouseId: this.config.warehouseId, + features: { darkMode: true }, + }; + } +} + +// Client — React component +import { usePluginClientConfig } from "@databricks/appkit-ui/react"; + +interface MyPluginConfig { warehouseId: string; features: { darkMode: boolean } } + +const config = usePluginClientConfig("myPlugin"); +config.warehouseId; // "abc-123" + +// Client — vanilla JS +import { getPluginClientConfig } from "@databricks/appkit-ui/js"; + +const config = getPluginClientConfig("myPlugin"); +``` + +#### Implementation of + +```ts +BasePlugin.clientConfig +``` + +*** + ### execute() ```ts diff --git a/packages/appkit-ui/src/js/config.test.ts b/packages/appkit-ui/src/js/config.test.ts new file mode 100644 index 00000000..f9441bd2 --- /dev/null +++ b/packages/appkit-ui/src/js/config.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + _resetConfigCache, + getClientConfig, + getPluginClientConfig, +} from "./config"; + +describe("js/config", () => { + afterEach(() => { + document.body.innerHTML = ""; + _resetConfigCache(); + }); + + test("parses runtime config from the DOM script payload", () => { + document.body.innerHTML = ` + + `; + + expect(getClientConfig()).toEqual({ + appName: "demo", + queries: { q: "q" }, + endpoints: { analytics: { query: "/api/analytics/query" } }, + plugins: { analytics: { warehouseId: "abc" } }, + }); + expect(getPluginClientConfig("analytics")).toEqual({ warehouseId: "abc" }); + }); + + test("returns empty config when no script tag is present", () => { + const config = getClientConfig(); + expect(config).toEqual({ + appName: "", + queries: {}, + endpoints: {}, + plugins: {}, + }); + }); + + test("returns empty config and warns on malformed JSON", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + document.body.innerHTML = ` + + `; + + const config = getClientConfig(); + expect(config).toEqual({ + appName: "", + queries: {}, + endpoints: {}, + plugins: {}, + }); + expect(warnSpy).toHaveBeenCalledWith( + "[appkit] Failed to parse config from DOM:", + expect.any(SyntaxError), + ); + warnSpy.mockRestore(); + }); + + test("caches parsed config across calls", () => { + document.body.innerHTML = ` + + `; + + const first = getClientConfig(); + const second = getClientConfig(); + expect(first).toBe(second); + }); + + test("returns stable reference for missing plugin config", () => { + document.body.innerHTML = ` + + `; + + const a = getPluginClientConfig("nonexistent"); + const b = getPluginClientConfig("nonexistent"); + expect(a).toBe(b); + }); + + test("returns empty config when script tag has empty content", () => { + document.body.innerHTML = ` + + `; + + const config = getClientConfig(); + expect(config).toEqual({ + appName: "", + queries: {}, + endpoints: {}, + plugins: {}, + }); + }); + + test("normalizes partial data with missing fields", () => { + document.body.innerHTML = ` + + `; + + const config = getClientConfig(); + expect(config).toEqual({ + appName: "partial", + queries: {}, + endpoints: {}, + plugins: {}, + }); + }); +}); diff --git a/packages/appkit-ui/src/js/config.ts b/packages/appkit-ui/src/js/config.ts new file mode 100644 index 00000000..2c5e6b6f --- /dev/null +++ b/packages/appkit-ui/src/js/config.ts @@ -0,0 +1,84 @@ +import type { PluginClientConfigs, PluginEndpoints } from "shared"; + +export interface AppKitClientConfig { + appName: string; + queries: Record; + endpoints: PluginEndpoints; + plugins: PluginClientConfigs; +} + +declare global { + interface Window { + __appkit__?: AppKitClientConfig; + } +} + +const APPKIT_CONFIG_SCRIPT_ID = "__appkit__"; +const EMPTY_CONFIG: AppKitClientConfig = Object.freeze({ + appName: "", + queries: Object.freeze({}), + endpoints: Object.freeze({}), + plugins: Object.freeze({}), +}); + +function normalizeClientConfig(config: unknown): AppKitClientConfig { + if (!config || typeof config !== "object") { + return EMPTY_CONFIG; + } + + const normalized = config as Partial; + + return { + appName: normalized.appName ?? EMPTY_CONFIG.appName, + queries: normalized.queries ?? EMPTY_CONFIG.queries, + endpoints: normalized.endpoints ?? EMPTY_CONFIG.endpoints, + plugins: normalized.plugins ?? EMPTY_CONFIG.plugins, + }; +} + +function readClientConfigFromDom(): AppKitClientConfig { + if (typeof document === "undefined") { + return EMPTY_CONFIG; + } + + const configScript = document.getElementById(APPKIT_CONFIG_SCRIPT_ID); + if (!configScript?.textContent) { + return EMPTY_CONFIG; + } + + try { + return normalizeClientConfig(JSON.parse(configScript.textContent)); + } catch (error) { + console.warn("[appkit] Failed to parse config from DOM:", error); + return EMPTY_CONFIG; + } +} + +let _cache: AppKitClientConfig | undefined; + +/** + * @internal Reset the module-scoped config cache. Test utility only. + */ +export function _resetConfigCache(): void { + _cache = undefined; +} + +export function getClientConfig(): AppKitClientConfig { + if (typeof window === "undefined") { + return EMPTY_CONFIG; + } + + if (!_cache) { + _cache = window.__appkit__ ?? readClientConfigFromDom(); + } + + return _cache; +} + +const EMPTY_PLUGIN_CONFIG = Object.freeze({}); + +export function getPluginClientConfig>( + pluginName: string, +): T { + return (getClientConfig().plugins[pluginName] ?? EMPTY_PLUGIN_CONFIG) as T; +} diff --git a/packages/appkit-ui/src/js/index.ts b/packages/appkit-ui/src/js/index.ts index 4350045a..f49cde96 100644 --- a/packages/appkit-ui/src/js/index.ts +++ b/packages/appkit-ui/src/js/index.ts @@ -10,5 +10,6 @@ export { sql, } from "shared"; export * from "./arrow"; +export * from "./config"; export * from "./constants"; export * from "./sse"; diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-plugin-config.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-plugin-config.test.ts new file mode 100644 index 00000000..97008e82 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-plugin-config.test.ts @@ -0,0 +1,57 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { _resetConfigCache } from "@/js/config"; +import { usePluginClientConfig } from "../use-plugin-config"; + +describe("usePluginClientConfig", () => { + afterEach(() => { + document.body.innerHTML = ""; + _resetConfigCache(); + }); + + test("returns typed plugin config from the boot payload", () => { + document.body.innerHTML = ` + + `; + + interface FilesConfig { + volumes: string[]; + } + + const { result } = renderHook(() => + usePluginClientConfig("files"), + ); + + expect(result.current).toEqual({ volumes: ["vol-a", "vol-b"] }); + }); + + test("returns empty object for unknown plugin", () => { + document.body.innerHTML = ` + + `; + + const { result } = renderHook(() => usePluginClientConfig("unknown")); + + expect(result.current).toEqual({}); + }); + + test("returns stable reference across re-renders", () => { + document.body.innerHTML = ` + + `; + + const { result, rerender } = renderHook(() => + usePluginClientConfig("genie"), + ); + + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index f4c52e6b..84d51b53 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -14,3 +14,4 @@ export { type UseChartDataResult, useChartData, } from "./use-chart-data"; +export { usePluginClientConfig } from "./use-plugin-config"; diff --git a/packages/appkit-ui/src/react/hooks/use-plugin-config.ts b/packages/appkit-ui/src/react/hooks/use-plugin-config.ts new file mode 100644 index 00000000..84d702be --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-plugin-config.ts @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { getPluginClientConfig } from "@/js"; + +/** + * Returns the client-side config exposed by a plugin's `clientConfig()` method. + * + * The value is read once from the boot-time ` `), })); @@ -152,12 +158,18 @@ describe("StaticServer", () => { handler({ path: "/" }, mockRes, mockNext); const sentHtml = mockRes.send.mock.calls[0][0]; - expect(sentHtml).toContain("window.__CONFIG__"); - expect(sentHtml).toContain("", + }, + }, + ); + + expect(script).toContain("\\u003c/script\\u003e"); + expect(script).not.toContain(""); + expect(script).toContain("window.__appkit__ = JSON.parse"); + }); + }); + + describe("sanitizeClientConfig", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test("passes through config whose values don't match any env var", () => { + process.env.DATABRICKS_HOST = "https://my-workspace.databricks.com"; + process.env.MY_CUSTOM_VAR = "some-value"; + + const config = { greeting: "hello", count: 42 }; + expect(sanitizeClientConfig("test", config)).toEqual(config); + }); + + test("redacts any non-public env var value found in the config", () => { + process.env.DATABRICKS_HOST = "https://secret.databricks.com"; + process.env.MY_API_KEY = "sk-abc-123"; + + const config = { + host: "https://secret.databricks.com", + apiKey: "sk-abc-123", + safe: "no-leak-here", + }; + + const result = sanitizeClientConfig("leaky-plugin", config); + + expect(result.host).toBe("[redacted by appkit]"); + expect(result.apiKey).toBe("[redacted by appkit]"); + expect(result.safe).toBe("no-leak-here"); + }); + + test("redacts deeply nested leaked values", () => { + process.env.DATABRICKS_HOST = "https://nested.databricks.com"; + + const config = { + outer: { + inner: { + url: "https://nested.databricks.com", + }, + }, + }; + + const result = sanitizeClientConfig("test", config) as any; + expect(result.outer.inner.url).toBe("[redacted by appkit]"); + }); + + test("allows PUBLIC_APPKIT_ env var values to pass through", () => { + process.env.PUBLIC_APPKIT_THEME = "dark"; + process.env.DATABRICKS_TOKEN = "secret"; + + const config = { + theme: "dark", + token: "secret", + }; + + const result = sanitizeClientConfig("test", config); + expect(result.theme).toBe("dark"); + expect(result.token).toBe("[redacted by appkit]"); + }); + + test("redacts env vars regardless of prefix", () => { + process.env.OTEL_ENDPOINT = "https://otel.internal"; + process.env.FLASK_RUN_HOST = "0.0.0.0"; + process.env.CUSTOM_SECRET = "my-secret-value"; + + const config = { + otelUrl: "https://otel.internal", + host: "0.0.0.0", + secret: "my-secret-value", + }; + + const result = sanitizeClientConfig("test", config); + expect(result.otelUrl).toBe("[redacted by appkit]"); + expect(result.host).toBe("[redacted by appkit]"); + expect(result.secret).toBe("[redacted by appkit]"); + }); + + test("redacts values wrapped with JSON.stringify", () => { + process.env.DATABRICKS_HOST = "https://secret.databricks.com"; + + const config = { + host: JSON.stringify("https://secret.databricks.com"), + }; + + const result = sanitizeClientConfig("test", config); + expect(result.host).toBe("[redacted by appkit]"); + }); + + test("redacts values embedded via string concatenation", () => { + process.env.DATABRICKS_TOKEN = "dapi-secret-token-123"; + + const config = { + auth: "Bearer " + "dapi-secret-token-123", + url: "https://host/" + "dapi-secret-token-123" + "/path", + }; + + const result = sanitizeClientConfig("test", config); + expect(result.auth).toBe("[redacted by appkit]"); + expect(result.url).toBe("[redacted by appkit]"); + }); + + test("redacts values embedded via template literals", () => { + process.env.MY_SECRET = "super-secret-value"; + + const secret = "super-secret-value"; + const config = { + message: `The secret is ${secret}`, + wrapped: `[${secret}]`, + }; + + const result = sanitizeClientConfig("test", config); + expect(result.message).toBe("[redacted by appkit]"); + expect(result.wrapped).toBe("[redacted by appkit]"); + }); + + test("redacts deeply nested embedded values", () => { + process.env.DATABRICKS_HOST = "https://nested.databricks.com"; + + const config = { + outer: { + inner: { + url: `https://nested.databricks.com/api/v1`, + }, + list: [`Host: https://nested.databricks.com`, "safe-value"], + }, + }; + + const result = sanitizeClientConfig("test", config) as any; + expect(result.outer.inner.url).toBe("[redacted by appkit]"); + expect(result.outer.list[0]).toBe("[redacted by appkit]"); + expect(result.outer.list[1]).toBe("safe-value"); + }); + + test("does not false-positive on short env values as substrings", () => { + process.env.SHORT_VAR = "ab"; + process.env.TINY = "x"; + + const config = { + text: "The alphabet starts with ab and x marks the spot", + }; + + const result = sanitizeClientConfig("test", config); + expect(result.text).toBe( + "The alphabet starts with ab and x marks the spot", + ); + }); + + test("still catches short env values on exact match", () => { + process.env.SHORT_VAR = "ab"; + + const config = { value: "ab" }; + const result = sanitizeClientConfig("test", config); + expect(result.value).toBe("[redacted by appkit]"); + }); + + test("does not redact public values whose text overlaps a non-public value", () => { + process.env.DATABRICKS_HOST = "internal.acme.example.com"; + process.env.PUBLIC_APPKIT_DATABRICKS_HOST = + "ext-internal.acme.example.com"; + + const config = { + host: "ext-internal.acme.example.com", + }; + + const result = sanitizeClientConfig("test", config); + expect(result.host).toBe("ext-internal.acme.example.com"); + }); + + test("still redacts non-public value even when a public value overlaps", () => { + process.env.DATABRICKS_HOST = "internal.acme.example.com"; + process.env.PUBLIC_APPKIT_DATABRICKS_HOST = + "ext-internal.acme.example.com"; + + const config = { + host: "internal.acme.example.com", + embedded: "Bearer internal.acme.example.com/api", + }; + + const result = sanitizeClientConfig("test", config); + expect(result.host).toBe("[redacted by appkit]"); + expect(result.embedded).toBe("[redacted by appkit]"); + }); + + test("does not redact when public and non-public vars share the same value", () => { + process.env.DATABRICKS_HOST = "shared.acme.example.com"; + process.env.PUBLIC_APPKIT_DATABRICKS_HOST = "shared.acme.example.com"; + + const config = { + host: "shared.acme.example.com", + wrapped: JSON.stringify("shared.acme.example.com"), + }; + + const result = sanitizeClientConfig("test", config); + expect(result.host).toBe("shared.acme.example.com"); + expect(result.wrapped).toBe('"shared.acme.example.com"'); + }); + + test("redacts leaked values even when an overlapping public value is also present", () => { + process.env.DATABRICKS_HOST = "internal.acme.example.com"; + process.env.PUBLIC_APPKIT_DATABRICKS_HOST = + "ext-internal.acme.example.com"; + + const config = { + mixed: + "public=ext-internal.acme.example.com private=internal.acme.example.com", + }; + + const result = sanitizeClientConfig("test", config); + expect(result.mixed).toBe("[redacted by appkit]"); + }); + + test("redacts longer secrets even when a public value overlaps as a substring", () => { + process.env.PUBLIC_APPKIT_LABEL = "abc"; + process.env.SECRET_LABEL = "abc123"; + + const result = sanitizeClientConfig("test", { + token: "Bearer abc123", + }); + + expect(result.token).toBe("[redacted by appkit]"); + }); + + test("redacts env-derived object keys", () => { + process.env.DATABRICKS_TOKEN = "secret-token"; + + const result = sanitizeClientConfig("test", { + "secret-token": true, + }); + + expect(result).toEqual({ + "[redacted by appkit]": true, + }); + }); + + test("keeps redacted object keys unique when multiple keys leak", () => { + process.env.DATABRICKS_TOKEN = "secret-token"; + + const result = sanitizeClientConfig("test", { + "prefix-secret-token": true, + "secret-token-suffix": false, + }); + + expect(result).toEqual({ + "[redacted by appkit]": true, + "[redacted by appkit] (2)": false, + }); + }); + + test("throws on non-serializable bigint values", () => { + expect(() => sanitizeClientConfig("test", { count: BigInt(1) })).toThrow( + /BigInt/, + ); + }); + + test("throws on circular references", () => { + const config: Record = {}; + config.self = config; + + expect(() => sanitizeClientConfig("test", config)).toThrow(/circular/); + }); + + test("throws on circular arrays", () => { + const config: Array = []; + config.push(config); + + expect(() => sanitizeClientConfig("test", { items: config })).toThrow( + /circular/, + ); + }); + + test("rejects reserved object keys like __proto__", () => { + const config = JSON.parse('{"__proto__":{"polluted":true}}'); + + expect(() => sanitizeClientConfig("test", config)).toThrow( + /reserved key/, + ); + }); + + test("omits undefined object fields and normalizes undefined array items", () => { + const result = sanitizeClientConfig("test", { + present: "value", + missing: undefined, + items: ["a", undefined, "b"], + }); + + expect(result).toEqual({ + present: "value", + items: ["a", null, "b"], + }); + }); + }); }); diff --git a/packages/appkit/src/plugins/server/utils.ts b/packages/appkit/src/plugins/server/utils.ts index 15671d93..0ed2bd50 100644 --- a/packages/appkit/src/plugins/server/utils.ts +++ b/packages/appkit/src/plugins/server/utils.ts @@ -3,6 +3,8 @@ import fs from "node:fs"; import type http from "node:http"; import path from "node:path"; import pc from "picocolors"; +import type { PluginClientConfigs, PluginEndpoints } from "shared"; +import { createLogger } from "../../logging/logger"; export function parseCookies( req: http.IncomingMessage, @@ -132,32 +134,421 @@ export function getQueries(configFolder: string) { ); } -import type { PluginEndpoints } from "shared"; - -export type { PluginEndpoints }; +export type { PluginClientConfigs, PluginEndpoints }; interface RuntimeConfig { appName: string; queries: Record; endpoints: PluginEndpoints; + plugins: PluginClientConfigs; } -function getRuntimeConfig(endpoints: PluginEndpoints = {}): RuntimeConfig { +const APPKIT_CONFIG_SCRIPT_ID = "__appkit__"; +const REDACTED_CLIENT_CONFIG_VALUE = "[redacted by appkit]"; +const MIN_SUBSTRING_LENGTH = 3; +const DISALLOWED_CLIENT_CONFIG_KEYS = new Set([ + "__proto__", + "constructor", + "prototype", +]); +const EMPTY_RUNTIME_CONFIG: RuntimeConfig = { + appName: "", + queries: {}, + endpoints: {}, + plugins: {}, +}; +const EMPTY_RUNTIME_CONFIG_JSON = JSON.stringify(EMPTY_RUNTIME_CONFIG); +const JSON_SCRIPT_ESCAPE_MAP: Record = { + "<": "\\u003c", + ">": "\\u003e", + "&": "\\u0026", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +export function getRuntimeConfig( + endpoints: PluginEndpoints = {}, + pluginConfigs: PluginClientConfigs = {}, +): RuntimeConfig { const configFolder = path.join(process.cwd(), "config"); return { appName: process.env.DATABRICKS_APP_NAME || "", queries: getQueries(configFolder), endpoints, + plugins: pluginConfigs, }; } -export function getConfigScript(endpoints: PluginEndpoints = {}): string { - const config = getRuntimeConfig(endpoints); +export function getConfigScript( + endpoints: PluginEndpoints = {}, + pluginConfigs: PluginClientConfigs = {}, +): string { + const config = getRuntimeConfig(endpoints, pluginConfigs); return ` + `; } + +const logger = createLogger("server:config"); + +function serializeRuntimeConfig(config: RuntimeConfig): string { + return JSON.stringify(config).replace( + /[<>&\u2028\u2029]/g, + (char) => JSON_SCRIPT_ESCAPE_MAP[char] ?? char, + ); +} + +/** + * Builds a Map of non-public env var values (value -> key name) + * and a Set of public env var values for overlap resolution. + */ +function getEnvValueSets(): { + nonPublic: Map; + publicValues: Set; +} { + const nonPublic = new Map(); + const publicValues = new Set(); + for (const [key, value] of Object.entries(process.env)) { + if (!value) continue; + if (key.startsWith("PUBLIC_APPKIT_")) { + publicValues.add(value); + } else { + nonPublic.set(value, key); + } + } + return { nonPublic, publicValues }; +} + +function getMatchRanges( + haystack: string, + needle: string, +): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + let startIndex = 0; + + while (startIndex < haystack.length) { + const matchIndex = haystack.indexOf(needle, startIndex); + if (matchIndex === -1) { + break; + } + ranges.push([matchIndex, matchIndex + needle.length]); + startIndex = matchIndex + 1; + } + + return ranges; +} + +function isSecretCoveredByPublicValue( + value: string, + envValue: string, + publicValues: Set, +): boolean { + const publicRanges = [...publicValues] + .filter((publicValue) => publicValue.includes(envValue)) + .flatMap((publicValue) => getMatchRanges(value, publicValue)); + + if (publicRanges.length === 0) { + return false; + } + + return getMatchRanges(value, envValue).every(([secretStart, secretEnd]) => + publicRanges.some( + ([publicStart, publicEnd]) => + publicStart <= secretStart && publicEnd >= secretEnd, + ), + ); +} + +function isPlainObject(value: object): boolean { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function invalidClientConfig( + pluginName: string, + path: string, + message: string, +): Error { + return new Error( + `Plugin '${pluginName}' clientConfig() ${message} at ${path}. Only JSON-serializable plain data is supported.`, + ); +} + +function assertSafeClientConfigKey( + pluginName: string, + key: string, + path: string, +): void { + if (DISALLOWED_CLIENT_CONFIG_KEYS.has(key)) { + throw invalidClientConfig( + pluginName, + `${path}.${key}`, + "contains a reserved key", + ); + } +} + +function validateClientConfigValue( + pluginName: string, + value: unknown, + path: string, + stack: WeakSet, +): unknown { + if (value === null) return null; + + switch (typeof value) { + case "string": + case "boolean": + return value; + case "number": + if (!Number.isFinite(value)) { + throw invalidClientConfig( + pluginName, + path, + "contains a non-finite number", + ); + } + return value; + case "bigint": + throw invalidClientConfig(pluginName, path, "contains a BigInt"); + case "undefined": + return undefined; + case "function": + throw invalidClientConfig(pluginName, path, "contains a function"); + case "symbol": + throw invalidClientConfig(pluginName, path, "contains a symbol"); + } + + if (Array.isArray(value)) { + if (stack.has(value)) { + throw invalidClientConfig( + pluginName, + path, + "contains a circular reference", + ); + } + + stack.add(value); + const result = value.map( + (item, index) => + validateClientConfigValue( + pluginName, + item, + `${path}[${index}]`, + stack, + ) ?? null, + ); + stack.delete(value); + return result; + } + + if (typeof value === "object") { + if (!isPlainObject(value)) { + throw invalidClientConfig( + pluginName, + path, + "contains a non-plain object", + ); + } + if (stack.has(value)) { + throw invalidClientConfig( + pluginName, + path, + "contains a circular reference", + ); + } + + stack.add(value); + const result: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + assertSafeClientConfigKey(pluginName, key, path); + const normalizedValue = validateClientConfigValue( + pluginName, + nestedValue, + `${path}.${key}`, + stack, + ); + if (normalizedValue !== undefined) { + result[key] = normalizedValue; + } + } + stack.delete(value); + return result; + } + + throw invalidClientConfig(pluginName, path, "contains an unsupported value"); +} + +function validateClientConfig( + pluginName: string, + config: unknown, +): Record { + if (config === null || typeof config !== "object" || Array.isArray(config)) { + throw new Error( + `Plugin '${pluginName}' clientConfig() must return a plain object.`, + ); + } + + return validateClientConfigValue( + pluginName, + config, + "clientConfig()", + new WeakSet(), + ) as Record; +} + +/** + * Redacts a string when it contains a non-public env var value. Exact matches + * are caught regardless of length; substring containment requires the env value + * to be at least MIN_SUBSTRING_LENGTH chars to avoid false positives from very + * short values. + */ +function redactLeakedString( + value: string, + nonPublicValues: Map, + publicValues: Set, + leakedVars: Set, +): string { + for (const [envValue, envKey] of nonPublicValues) { + if (value === envValue && !publicValues.has(envValue)) { + leakedVars.add(envKey); + return REDACTED_CLIENT_CONFIG_VALUE; + } + if ( + envValue.length >= MIN_SUBSTRING_LENGTH && + value.includes(envValue) && + !isSecretCoveredByPublicValue(value, envValue, publicValues) + ) { + leakedVars.add(envKey); + return REDACTED_CLIENT_CONFIG_VALUE; + } + } + + return value; +} + +function redactLeakedValues( + obj: unknown, + nonPublicValues: Map, + publicValues: Set, + leakedVars: Set, +): unknown { + if (typeof obj === "string") { + return redactLeakedString(obj, nonPublicValues, publicValues, leakedVars); + } + + if (Array.isArray(obj)) { + return obj.map((item) => + redactLeakedValues(item, nonPublicValues, publicValues, leakedVars), + ); + } + + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const redactedKey = redactLeakedString( + key, + nonPublicValues, + publicValues, + leakedVars, + ); + const uniqueKey = getUniqueObjectKey(redactedKey, result); + result[uniqueKey] = redactLeakedValues( + value, + nonPublicValues, + publicValues, + leakedVars, + ); + } + return result; + } + + return obj; +} + +function getUniqueObjectKey( + key: string, + result: Record, +): string { + if (!Object.hasOwn(result, key)) { + return key; + } + + let suffix = 2; + let candidate = `${key} (${suffix})`; + while (Object.hasOwn(result, candidate)) { + suffix += 1; + candidate = `${key} (${suffix})`; + } + + return candidate; +} + +/** + * Scans a plugin's clientConfig return value for string values that + * match or contain non-public environment variable values. Matches are + * replaced with "[redacted by appkit]" and a warning is logged. + * + * Only env vars prefixed with `PUBLIC_APPKIT_` are allowed through; + * all other process.env values are treated as sensitive. + */ +export function sanitizeClientConfig( + pluginName: string, + config: unknown, +): Record { + const validated = validateClientConfig(pluginName, config); + const { nonPublic, publicValues } = getEnvValueSets(); + if (nonPublic.size === 0) return validated; + + const leakedVars = new Set(); + const sanitized = redactLeakedValues( + validated, + nonPublic, + publicValues, + leakedVars, + ) as Record; + + if (leakedVars.size > 0) { + const banner = formatLeakedVarsBanner(pluginName, leakedVars); + logger.warn("\n\n%s\n", banner); + } + + return sanitized; +} + +function formatLeakedVarsBanner( + pluginName: string, + leakedVars: Set, +): string { + const s = leakedVars.size === 1 ? "" : "s"; + const contentLines: string[] = [ + `${pc.bold(pluginName)}.clientConfig() contained ${pc.bold(String(leakedVars.size))} env var value${s}`, + `that would have been sent to the browser. AppKit ${pc.green("redacted")} them automatically.`, + "", + ...Array.from(leakedVars, (v) => ` ${pc.red("-")} ${pc.yellow(v)}`), + "", + `To intentionally expose a value, set a matching ${pc.green("PUBLIC_APPKIT_")} variable.`, + `Example: ${pc.dim('PUBLIC_APPKIT_MY_VAR="safe-value"')}`, + ]; + + // biome-ignore lint: stripping ANSI escape sequences requires matching the ESC control character + const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*m/g, ""); + const maxLen = Math.max(...contentLines.map((l) => stripAnsi(l).length)); + const border = pc.yellow("=".repeat(maxLen + 4)); + const boxed = contentLines.map( + (line) => + `${pc.yellow("|")} ${line}${" ".repeat(maxLen - stripAnsi(line).length)} ${pc.yellow("|")}`, + ); + + return [border, ...boxed, border].join("\n"); +} diff --git a/packages/appkit/src/plugins/server/vite-dev-server.ts b/packages/appkit/src/plugins/server/vite-dev-server.ts index f6f33fec..65182d15 100644 --- a/packages/appkit/src/plugins/server/vite-dev-server.ts +++ b/packages/appkit/src/plugins/server/vite-dev-server.ts @@ -7,7 +7,7 @@ import { ServerError } from "../../errors"; import { createLogger } from "../../logging/logger"; import { appKitTypesPlugin } from "../../type-generator/vite-plugin"; import { BaseServer } from "./base-server"; -import type { PluginEndpoints } from "./utils"; +import type { PluginClientConfigs, PluginEndpoints } from "./utils"; const logger = createLogger("server:vite"); @@ -26,8 +26,12 @@ const logger = createLogger("server:vite"); export class ViteDevServer extends BaseServer { private vite: ViteDevServerType | null; - constructor(app: express.Application, endpoints: PluginEndpoints = {}) { - super(app, endpoints); + constructor( + app: express.Application, + endpoints: PluginEndpoints = {}, + pluginConfigs: PluginClientConfigs = {}, + ) { + super(app, endpoints, pluginConfigs); this.vite = null; } diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 761bdce6..9fa8066c 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -24,6 +24,8 @@ export interface BasePlugin { getSkipBodyParsingPaths?(): ReadonlySet; exports?(): unknown; + + clientConfig?(): Record; } /** Base configuration interface for AppKit plugins */ @@ -216,6 +218,9 @@ export type PluginEndpointMap = Record; /** Map of plugin names to their endpoint maps */ export type PluginEndpoints = Record; +/** Map of plugin names to their client-exposed config */ +export type PluginClientConfigs = Record>; + export interface QuerySchemas { [key: string]: unknown; }