diff --git a/cli/src/backends/aws.ts b/cli/src/backends/aws.ts index 9c5db74b..0c90544e 100644 --- a/cli/src/backends/aws.ts +++ b/cli/src/backends/aws.ts @@ -15,6 +15,7 @@ import { CliError, dim, errMessage, header, note, ok, step, warn } from "../log. import { awsWorkloadArchitecture, isDigestPinned, + portalPluginRoutesEnv, sandboxCoreEnv, securityScreenEnv, type AwsConfig, @@ -261,6 +262,11 @@ export function serviceEnvironment(config: QmConfig, service: ServiceName): Reco env.WEB_UI_UPSTREAM = `http://web-ui.${aws.networking.cloudMapNamespace}:8080`; env.ADMIN_UPSTREAM = `http://admin.${aws.networking.cloudMapNamespace}:8080`; env.PORTAL_XFF_TRUSTED_HOPS = "1"; + const pluginRoutes = portalPluginRoutesEnv( + config, + (plugin) => `http://${plugin}.${aws.networking.cloudMapNamespace}:8080`, + ); + if (pluginRoutes) env.PORTAL_PLUGIN_ROUTES = pluginRoutes; } if (config.services.includes("auth")) { Object.assign( diff --git a/cli/src/backends/docker.ts b/cli/src/backends/docker.ts index 232f078a..0c50733e 100644 --- a/cli/src/backends/docker.ts +++ b/cli/src/backends/docker.ts @@ -28,7 +28,7 @@ import { type LogOpts, type ServiceName, } from "../services.ts"; -import { dockerBasePort, sandboxCoreEnv, securityScreenEnv, type QmConfig } from "../config.ts"; +import { dockerBasePort, portalPluginRoutesEnv, sandboxCoreEnv, securityScreenEnv, type QmConfig } from "../config.ts"; import { discoverPlugins, type ResolvedPlugin } from "../plugins.ts"; import { computedSecrets, runtimeSecretNames, secretsForService } from "../secrets.ts"; import { readDeploymentState, withDeploymentLock, writeDeploymentState, type DeploymentState } from "../state.ts"; @@ -285,6 +285,8 @@ export function dockerServiceEnv(config: QmConfig, service: ServiceName): Record if (service === "portal") { if (config.services.includes("web-ui")) out.WEB_UI_UPSTREAM = "http://web-ui:8080"; if (config.services.includes("admin")) out.ADMIN_UPSTREAM = "http://admin:8080"; + const pluginRoutes = portalPluginRoutesEnv(config, (plugin) => `http://${plugin}:8080`); + if (pluginRoutes) out.PORTAL_PLUGIN_ROUTES = pluginRoutes; } if (config.services.includes("auth")) { Object.assign( diff --git a/cli/src/backends/fly.ts b/cli/src/backends/fly.ts index 0940eb03..6bc03ed0 100644 --- a/cli/src/backends/fly.ts +++ b/cli/src/backends/fly.ts @@ -29,6 +29,7 @@ import { import { appPrefixOf, CONFIG_FILENAME, + portalPluginRoutesEnv, sandboxCoreEnv, securityScreenEnv, updateConfigImageOverrides, @@ -199,6 +200,10 @@ function deriveToml(ctx: FlyCtx, service: ServiceName): string { ...(sandboxEnv.FLY_BASE_IMAGE ? { FLY_DEPLOY_BASE_IMAGE: sandboxEnv.FLY_BASE_IMAGE } : {}), } : {}; + const portalRoutes = + service === "portal" + ? portalPluginRoutesEnv(ctx.config, (plugin) => `http://${ctx.appPrefix}-${plugin}.internal:8080`) + : undefined; const overrides: Record = { ...spec.managed(ctx.serviceCtx), ...sandboxEnv, @@ -207,6 +212,7 @@ function deriveToml(ctx: FlyCtx, service: ServiceName): string { ...configuredEnv, ...(service === "core" ? securityScreenEnv(ctx.config) : {}), ...deploymentEnv, + ...(portalRoutes ? { PORTAL_PLUGIN_ROUTES: portalRoutes } : {}), [FLY_DEPLOYMENT_ID_ENV]: flyDeploymentId(ctx.flyOrg, ctx.orgId, ctx.appPrefix), }; const provided = new Set(Object.keys(overrides)); diff --git a/cli/src/config.ts b/cli/src/config.ts index 2277e65c..f2ddbc8d 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -38,6 +38,12 @@ export interface PluginEntry { secrets?: PluginSecret[]; } +export interface PortalRoute { + pathPrefix: string; + plugin: string; + access: "session" | "signed-upstream"; +} + export interface SandboxConfig { backend?: "sprites" | "aws"; app?: string; @@ -139,6 +145,7 @@ export interface QmConfig { basePort?: number; services: DeclaredServiceName[]; plugins: PluginEntry[]; + portalRoutes?: PortalRoute[]; skills: string[]; env: Partial>>; secretEnv?: Partial>>; @@ -165,6 +172,19 @@ export function securityScreenEnv(config: Pick): Rec }; } +export function portalPluginRoutesEnv( + config: Pick, + upstreamBaseFor: (plugin: string) => string, +): string | undefined { + const routes = [...(config.portalRoutes ?? [])].sort( + (left, right) => right.pathPrefix.length - left.pathPrefix.length || left.pathPrefix.localeCompare(right.pathPrefix), + ); + if (!routes.length) return undefined; + return JSON.stringify( + routes.map(({ pathPrefix, plugin, access }) => ({ pathPrefix, access, upstreamBase: upstreamBaseFor(plugin) })), + ); +} + export function configPathInDir(dir: string): string | undefined { const candidate = resolve(dir, CONFIG_FILENAME); return existsSync(candidate) ? candidate : undefined; @@ -548,6 +568,7 @@ function validate(raw: unknown, path: string): QmConfig { } const plugins = validatePlugins(o["plugins"], path); + const portalRoutes = validatePortalRoutes(o["portalRoutes"], path, services, plugins); const skills = validateStringArray(o["skills"], path, "skills"); const env = validateServiceMap(o["env"], path, "env", (v, k) => validateStringMap(v, path, `env.${k}`)); const secretEnv = validateServiceMap(o["secretEnv"], path, "secretEnv", (v, k) => { @@ -627,6 +648,14 @@ function validate(raw: unknown, path: string): QmConfig { `${path}: "plugins[${i}].env.PORT" is managed by the deployment target and cannot be overridden`, ); } + if (plugin.env?.PORTAL_PLUGIN_ROUTES !== undefined) { + throw new CliError( + `${path}: "plugins[${i}].env.PORTAL_PLUGIN_ROUTES" is managed by portalRoutes and cannot be overridden`, + ); + } + } + if (env.portal?.PORTAL_PLUGIN_ROUTES !== undefined) { + throw new CliError(`${path}: "env.portal.PORTAL_PLUGIN_ROUTES" is managed by portalRoutes and cannot be overridden`); } const imageOverrides = validateServiceMap(o["imageOverrides"], path, "imageOverrides", (v, k) => { if (typeof v !== "string") throw new CliError(`${path}: "imageOverrides.${k}" must be a string`); @@ -641,6 +670,7 @@ function validate(raw: unknown, path: string): QmConfig { target, services, plugins, + portalRoutes, skills, env, imageOverrides, @@ -710,6 +740,62 @@ function validate(raw: unknown, path: string): QmConfig { return out; } +const BUILT_IN_PORTAL_PREFIXES = ["/", "/admin", "/auth", "/idp", "/api", "/v1", "/connect", "/drop", "/d"]; + +function portalPrefixesOverlap(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`); +} + +function validatePortalRoutes( + raw: unknown, + path: string, + services: DeclaredServiceName[], + plugins: PluginEntry[], +): PortalRoute[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) throw new CliError(`${path}: "portalRoutes" must be an array`); + if (raw.length && !services.includes("portal")) { + throw new CliError(`${path}: "portalRoutes" requires the "portal" service`); + } + const pluginNames = new Set(plugins.map((plugin) => plugin.name)); + const routes = raw.map((value, index): PortalRoute => { + const field = `portalRoutes[${index}]`; + if (!isPlainObject(value)) throw new CliError(`${path}: ${field} must be an object`); + const allowed = new Set(["pathPrefix", "plugin", "access"]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new CliError(`${path}: ${field}.${key} is not recognized`); + } + const pathPrefix = value.pathPrefix; + if ( + typeof pathPrefix !== "string" || + !/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(pathPrefix) || + /%2f|%5c/i.test(pathPrefix) + ) { + throw new CliError(`${path}: ${field}.pathPrefix must be a normalized absolute path prefix without encoded separators`); + } + if (BUILT_IN_PORTAL_PREFIXES.some((builtIn) => builtIn !== "/" && portalPrefixesOverlap(pathPrefix, builtIn))) { + throw new CliError(`${path}: ${field}.pathPrefix collides with a built-in portal route`); + } + const plugin = value.plugin; + if (typeof plugin !== "string" || !pluginNames.has(plugin)) { + throw new CliError(`${path}: ${field}.plugin names an unknown plugin`); + } + const access = value.access; + if (access !== "session" && access !== "signed-upstream") { + throw new CliError(`${path}: ${field}.access must be "session" or "signed-upstream"`); + } + return { pathPrefix, plugin, access }; + }); + for (let i = 0; i < routes.length; i += 1) { + for (let j = i + 1; j < routes.length; j += 1) { + if (portalPrefixesOverlap(routes[i]!.pathPrefix, routes[j]!.pathPrefix)) { + throw new CliError(`${path}: portalRoutes path prefixes overlap`); + } + } + } + return routes; +} + function configuredHarness(config: QmConfig): string { return config.env.core?.HARNESS?.trim() || (config.target === "fly" ? "pi" : "mock"); } diff --git a/cli/test/auth-broker.test.ts b/cli/test/auth-broker.test.ts index f7711ccc..a4289593 100644 --- a/cli/test/auth-broker.test.ts +++ b/cli/test/auth-broker.test.ts @@ -120,6 +120,32 @@ test("docker and AWS wire the broker with parity", () => { assert.equal(serviceEnvironment(aws, "auth").PORT, "8080"); }); +test("docker and Fly derive portal plugin upstreams from deployment topology", () => { + const docker = configWith(`{ + "contract": 1, "orgId": "acme", "publicUrl": "https://agent.example.com", "target": "docker", + "services": ["core", "web-ui", "portal"], + "plugins": [{ "name": "programme" }, { "name": "edge-registry" }], "skills": [], + "portalRoutes": [ + { "pathPrefix": "/edge/v1", "plugin": "edge-registry", "access": "signed-upstream" }, + { "pathPrefix": "/programme", "plugin": "programme", "access": "session" } + ], + "env": {} + }`); + assert.deepEqual(JSON.parse(dockerServiceEnv(docker, "portal").PORTAL_PLUGIN_ROUTES!), [ + { pathPrefix: "/programme", access: "session", upstreamBase: "http://programme:8080" }, + { pathPrefix: "/edge/v1", access: "signed-upstream", upstreamBase: "http://edge-registry:8080" }, + ]); + + const fly: QmConfig = { ...docker, target: "fly", appPrefix: "ycqm", region: "sjc", flyOrg: "acme" }; + const portal = derivedTomlFor(fly, "portal", repoRoot); + const encoded = portal.match(/PORTAL_PLUGIN_ROUTES = (.+)/)?.[1]; + assert.ok(encoded); + assert.deepEqual(JSON.parse(JSON.parse(encoded)), [ + { pathPrefix: "/programme", access: "session", upstreamBase: "http://ycqm-programme.internal:8080" }, + { pathPrefix: "/edge/v1", access: "signed-upstream", upstreamBase: "http://ycqm-edge-registry.internal:8080" }, + ]); +}); + test("the broker's generated secrets reach both sides under the right names", () => { const config = brokerConfig(); const secrets = computedSecrets(config); diff --git a/cli/test/aws.test.ts b/cli/test/aws.test.ts index 8f045e89..70adfba4 100644 --- a/cli/test/aws.test.ts +++ b/cli/test/aws.test.ts @@ -467,6 +467,21 @@ test("AWS environment derives identity, public URLs, private wiring, and MicroVM assert.equal(core.PORT, "8080"); }); +test("AWS derives portal plugin routes from Cloud Map and sorts them deterministically", () => { + const routed: QmConfig = { + ...config, + plugins: [{ name: "programme" }, { name: "edge-registry" }], + portalRoutes: [ + { pathPrefix: "/edge/v1", plugin: "edge-registry", access: "signed-upstream" }, + { pathPrefix: "/programme", plugin: "programme", access: "session" }, + ], + }; + assert.deepEqual(JSON.parse(serviceEnvironment(routed, "portal").PORTAL_PLUGIN_ROUTES!), [ + { pathPrefix: "/programme", access: "session", upstreamBase: "http://programme.acme.internal:8080" }, + { pathPrefix: "/edge/v1", access: "signed-upstream", upstreamBase: "http://edge-registry.acme.internal:8080" }, + ]); +}); + test("AWS routes security screen proxy configuration and its token only to core", () => { const screened: QmConfig = { ...config, diff --git a/cli/test/config.test.ts b/cli/test/config.test.ts index 7d62298d..af48c51b 100644 --- a/cli/test/config.test.ts +++ b/cli/test/config.test.ts @@ -112,6 +112,64 @@ test("plugins: image is OPTIONAL (source plugins); env attaches to either; bad i ); }); +test("portalRoutes validates session and signed-upstream plugin mounts", () => { + const portalRoutes = [ + { pathPrefix: "/programme", plugin: "programme", access: "session" }, + { pathPrefix: "/edge/v1", plugin: "edge-registry", access: "signed-upstream" }, + ]; + withConfig( + { + services: ["core", "web-ui", "portal"], + plugins: [{ name: "programme" }, { name: "edge-registry" }], + portalRoutes, + }, + ({ path }) => assert.deepEqual(loadConfigAt(path).config.portalRoutes, portalRoutes), + ); +}); + +test("portalRoutes rejects unsafe, ambiguous, and unresolved mounts", () => { + const base = { services: ["core", "portal"], plugins: [{ name: "programme" }, { name: "edge-registry" }] }; + for (const pathPrefix of ["programme", "/", "/admin", "/api/jobs", "/edge%2fv1", "/edge%5Cv1", "/edge/"]) { + withConfig( + { ...base, portalRoutes: [{ pathPrefix, plugin: "programme", access: "session" }] }, + ({ path }) => assert.throws(() => loadConfigAt(path), /portalRoutes/), + ); + } + withConfig( + { ...base, portalRoutes: [{ pathPrefix: "/programme", plugin: "missing", access: "session" }] }, + ({ path }) => assert.throws(() => loadConfigAt(path), /unknown plugin/), + ); + withConfig( + { + ...base, + portalRoutes: [ + { pathPrefix: "/edge", plugin: "edge-registry", access: "session" }, + { pathPrefix: "/edge/v1", plugin: "edge-registry", access: "signed-upstream" }, + ], + }, + ({ path }) => assert.throws(() => loadConfigAt(path), /overlap/), + ); + withConfig( + { + services: ["core"], + plugins: [{ name: "programme" }], + portalRoutes: [{ pathPrefix: "/programme", plugin: "programme", access: "session" }], + }, + ({ path }) => assert.throws(() => loadConfigAt(path), /requires.*portal/), + ); +}); + +test("PORTAL_PLUGIN_ROUTES is deployment-managed", () => { + withConfig( + { services: ["core", "portal"], env: { portal: { PORTAL_PLUGIN_ROUTES: "[]" } } }, + ({ path }) => assert.throws(() => loadConfigAt(path), /PORTAL_PLUGIN_ROUTES.*managed/), + ); + withConfig( + { plugins: [{ name: "programme", env: { PORTAL_PLUGIN_ROUTES: "[]" } }] }, + ({ path }) => assert.throws(() => loadConfigAt(path), /PORTAL_PLUGIN_ROUTES.*managed/), + ); +}); + test("env (per-service) and imageOverrides validate by service name", () => { withConfig({ env: { core: { PUBLIC_WEB_URL: "http://x" } }, imageOverrides: { core: "ghcr.io/x:1" } }, ({ path }) => { const { config } = loadConfigAt(path); diff --git a/package-lock.json b/package-lock.json index be8f50f3..09617ba7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@aws-sdk/client-sts": "^3.1075.0", "@aws-sdk/credential-provider-node": "^3.972.70", "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.2/earendil-works-pi-coding-agent-0.82.0-qm-security.2.tgz", + "@earendil-works/pi-coding-agent": "https://github.com/TrueKrishna/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", "@fly/sprites": "0.0.1", "@openai/codex": "0.144.5", "@opencode-ai/plugin": "1.17.18", @@ -916,8 +916,8 @@ }, "node_modules/@earendil-works/pi-coding-agent": { "version": "0.82.0", - "resolved": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.2/earendil-works-pi-coding-agent-0.82.0-qm-security.2.tgz", - "integrity": "sha512-og2SkJ3OMhaklOGNMONCo6Gka70xpPaAvgUymjhtro6deKTAUebHWldPPrINmYsYgUNCycWV1gp3PMBRXjQfWw==", + "resolved": "https://github.com/TrueKrishna/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", + "integrity": "sha512-YKmxnCk6JDVbEnM7tEzYdJ+rwnbU/SPlWxNuum6PzHxsWknKzy9eIe5ueg38P/R0A5qoOl0BKPD3sUp9JahVyw==", "hasShrinkwrap": true, "license": "MIT", "dependencies": { @@ -3941,9 +3941,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4701,9 +4701,9 @@ } }, "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", - "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.2.tgz", + "integrity": "sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==", "funding": [ { "type": "github", @@ -4740,9 +4740,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -5278,9 +5278,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -7616,9 +7616,9 @@ } }, "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index 1cdc313d..6476e713 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@aws-sdk/client-sts": "^3.1075.0", "@aws-sdk/credential-provider-node": "^3.972.70", "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.2/earendil-works-pi-coding-agent-0.82.0-qm-security.2.tgz", + "@earendil-works/pi-coding-agent": "https://github.com/TrueKrishna/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", "@fly/sprites": "0.0.1", "@openai/codex": "0.144.5", "@opencode-ai/plugin": "1.17.18", @@ -80,16 +80,18 @@ "overrides": { "@hono/node-server": "2.0.10", "@fastify/ajv-compiler": { - "fast-uri": "3.1.4" + "fast-uri": "3.1.5" }, "ajv": { - "fast-uri": "3.1.4" + "fast-uri": "3.1.5" }, - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", "fast-json-stringify": { - "fast-uri": "4.1.1" + "fast-uri": "4.1.2" }, - "protobufjs": "7.6.5" + "hono": "4.13.1", + "protobufjs": "7.6.5", + "undici": "8.10.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/plugins/portal/src/index.ts b/plugins/portal/src/index.ts index 2ecde29c..e231b54e 100644 --- a/plugins/portal/src/index.ts +++ b/plugins/portal/src/index.ts @@ -35,6 +35,7 @@ import { FORWARD_DEPLOYMENT_LAYER_HEADERS, FORWARD_OAUTH_HEADERS, FORWARD_BROKER_HEADERS, + FORWARD_SIGNED_PLUGIN_HEADERS, } from "./proxy.ts"; import { signedHeaders, withSourceAuthNonce } from "../../chassis/src/core-client.ts"; import { coreClaimStore, withinRateLimit } from "../../chassis/src/claims.ts"; @@ -118,6 +119,110 @@ const UPSTREAMS: Record = { }; const COOKIE_FOR: Record = { "web-ui": "webuiuser", admin: "admin" }; +export interface PluginRoute { + pathPrefix: string; + access: "session" | "signed-upstream"; + upstreamBase: string; +} + +const BUILT_IN_PREFIXES = ["/admin", "/auth", "/idp", "/api", "/v1", "/connect", "/drop", "/d"]; + +function prefixesOverlap(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`); +} + +function privateUpstreamHost(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (!host.includes(".") || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal") || host.endsWith(".local")) return true; + if (host === "::1" || host.startsWith("fc") || host.startsWith("fd")) return true; + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; + return ( + octets[0] === 10 || + octets[0] === 127 || + (octets[0] === 169 && octets[1] === 254) || + (octets[0] === 172 && octets[1]! >= 16 && octets[1]! <= 31) || + (octets[0] === 192 && octets[1] === 168) + ); +} + +export function parsePluginRoutes(raw: string | undefined): PluginRoute[] { + if (!raw?.trim()) return []; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("PORTAL_PLUGIN_ROUTES must be valid JSON"); + } + if (!Array.isArray(value)) throw new Error("PORTAL_PLUGIN_ROUTES must be an array"); + const routes = value.map((entry, index): PluginRoute => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`PORTAL_PLUGIN_ROUTES[${index}] must be an object`); + } + const item = entry as Record; + if ( + Object.keys(item).length !== 3 || + !Object.hasOwn(item, "pathPrefix") || + !Object.hasOwn(item, "access") || + !Object.hasOwn(item, "upstreamBase") + ) { + throw new Error(`PORTAL_PLUGIN_ROUTES[${index}] must contain exactly pathPrefix, access, and upstreamBase`); + } + const pathPrefix = item.pathPrefix; + if (typeof pathPrefix !== "string" || !/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(pathPrefix)) { + throw new Error(`PORTAL_PLUGIN_ROUTES[${index}].pathPrefix is not normalized`); + } + if (BUILT_IN_PREFIXES.some((builtIn) => prefixesOverlap(pathPrefix, builtIn))) { + throw new Error(`PORTAL_PLUGIN_ROUTES[${index}].pathPrefix collides with a built-in route`); + } + const access = item.access; + if (access !== "session" && access !== "signed-upstream") { + throw new Error(`PORTAL_PLUGIN_ROUTES[${index}].access is invalid`); + } + if (typeof item.upstreamBase !== "string") { + throw new Error(`PORTAL_PLUGIN_ROUTES[${index}].upstreamBase is invalid`); + } + let upstream: URL; + try { + upstream = new URL(item.upstreamBase); + } catch { + throw new Error(`PORTAL_PLUGIN_ROUTES[${index}].upstreamBase is invalid`); + } + if ( + (upstream.protocol !== "http:" && upstream.protocol !== "https:") || + upstream.username || + upstream.password || + upstream.pathname !== "/" || + upstream.search || + upstream.hash || + !privateUpstreamHost(upstream.hostname) + ) { + throw new Error(`PORTAL_PLUGIN_ROUTES[${index}].upstreamBase must be a private HTTP(S) origin`); + } + return { pathPrefix, access, upstreamBase: upstream.origin }; + }); + for (let left = 0; left < routes.length; left += 1) { + for (let right = left + 1; right < routes.length; right += 1) { + if (prefixesOverlap(routes[left]!.pathPrefix, routes[right]!.pathPrefix)) { + throw new Error("PORTAL_PLUGIN_ROUTES path prefixes overlap"); + } + } + } + return routes.sort( + (left, right) => right.pathPrefix.length - left.pathPrefix.length || left.pathPrefix.localeCompare(right.pathPrefix), + ); +} + +const PLUGIN_ROUTES = parsePluginRoutes(process.env.PORTAL_PLUGIN_ROUTES); + +export function pluginRouteFor(pathname: string): (PluginRoute & { forwardPath: string }) | undefined { + const route = PLUGIN_ROUTES.find( + ({ pathPrefix }) => pathname === pathPrefix || pathname.startsWith(`${pathPrefix}/`), + ); + if (!route) return undefined; + return { ...route, forwardPath: pathname.slice(route.pathPrefix.length) || "/" }; +} + const OIDC: OidcConfig = { authEndpoint: process.env.OIDC_AUTH_ENDPOINT ?? "https://slack.com/openid/connect/authorize", tokenEndpoint: process.env.OIDC_TOKEN_ENDPOINT ?? "https://slack.com/api/openid.connect.token", @@ -923,6 +1028,16 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise return json(res, 400, { error: "bad_request", message: "illegal path" }); } + const pluginRoute = pluginRouteFor(pathname); + if (pluginRoute?.access === "signed-upstream") { + return proxyToUpstream( + req, + res, + { baseUrl: pluginRoute.upstreamBase, path: pluginRoute.forwardPath, search: url.search }, + FORWARD_SIGNED_PLUGIN_HEADERS, + ); + } + const consentBounce = (): void => { res.writeHead(302, { location: `/auth/login?returnTo=${encodeURIComponent(`${pathname}${url.search}`)}` }); return void res.end(); @@ -1018,6 +1133,17 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise return json(res, 403, { error: "forbidden", message: "cross-origin request refused" }); } + if (pluginRoute?.access === "session") { + return proxyToSurface(req, res, { + upstreamBase: pluginRoute.upstreamBase, + forwardPath: pluginRoute.forwardPath, + search: url.search, + principal: session.sub, + ...(session.name ? { displayName: session.name } : {}), + ...(PORTAL_IDENTITY_SECRET ? { identitySecret: PORTAL_IDENTITY_SECRET } : {}), + }); + } + if (isDeployment) { const rest = pathname.slice(`/${seg}/`.length); const slash = rest.indexOf("/"); diff --git a/plugins/portal/src/proxy.ts b/plugins/portal/src/proxy.ts index 0c96f198..2d147181 100644 --- a/plugins/portal/src/proxy.ts +++ b/plugins/portal/src/proxy.ts @@ -77,7 +77,7 @@ export interface SurfaceTarget { upstreamBase: string; forwardPath: string; search: string; - cookieName: string; + cookieName?: string; principal: string; displayName?: string; impersonator?: string; @@ -87,11 +87,13 @@ export interface SurfaceTarget { export function proxyToSurface(req: IncomingMessage, res: ServerResponse, t: SurfaceTarget): void { const upstream = new URL(t.upstreamBase); - const cookie = - `${t.cookieName}=${encodeURIComponent(t.principal)}` + - (t.displayName ? `; ${t.cookieName}_name=${encodeURIComponent(t.displayName)}` : "") + - (t.impersonator ? `; webui_impersonator=${encodeURIComponent(t.impersonator)}` : ""); - const base: Record = { host: upstream.host, cookie }; + const base: Record = { host: upstream.host }; + if (t.cookieName) { + base.cookie = + `${t.cookieName}=${encodeURIComponent(t.principal)}` + + (t.displayName ? `; ${t.cookieName}_name=${encodeURIComponent(t.displayName)}` : "") + + (t.impersonator ? `; webui_impersonator=${encodeURIComponent(t.impersonator)}` : ""); + } if (t.identitySecret) { const now = t.nowMs ?? Date.now(); base[PORTAL_IDENTITY_HEADER] = mintPortalIdentity( @@ -197,3 +199,15 @@ export function proxyToUpstream( } export const FORWARD_BROKER_HEADERS = ["accept", "accept-language", "user-agent", "content-type", "content-length"]; + +export const FORWARD_SIGNED_PLUGIN_HEADERS = [ + "content-type", + "content-length", + "accept", + "authorization", + "x-qm-agent-id", + "x-qm-timestamp", + "x-qm-nonce", + "x-qm-body-digest", + "x-qm-signature", +] as const; diff --git a/plugins/portal/test/router.test.ts b/plugins/portal/test/router.test.ts index dac65928..65f459b2 100644 --- a/plugins/portal/test/router.test.ts +++ b/plugins/portal/test/router.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createServer, type IncomingMessage } from "node:http"; import type { AddressInfo } from "node:net"; +import { verifyPortalIdentity } from "../../chassis/src/portal-identity.ts"; let whoamiProbes = 0; let lastConsentClicker: string | null = null; @@ -81,8 +82,13 @@ process.env.CORE_SIGNING_SECRET = "router-test-core-secret"; process.env.WEB_UI_UPSTREAM = upstreamUrl; process.env.ADMIN_UPSTREAM = upstreamUrl; process.env.CORE_API_URL = upstreamUrl; +process.env.PORTAL_IDENTITY_SECRET = "router-test-identity-secret"; +process.env.PORTAL_PLUGIN_ROUTES = JSON.stringify([ + { pathPrefix: "/programme", access: "session", upstreamBase: upstreamUrl }, + { pathPrefix: "/edge/v1", access: "signed-upstream", upstreamBase: upstreamUrl }, +]); -const { server } = await import("../src/index.ts"); +const { server, parsePluginRoutes } = await import("../src/index.ts"); const { deriveKey, seal, open } = await import("../src/session.ts"); await new Promise((r) => server.listen(0, r)); const base = `http://localhost:${(server.address() as AddressInfo).port}`; @@ -104,6 +110,34 @@ test("healthz is unauthenticated", async () => { assert.equal(r.status, 200); }); +test("plugin route parsing fails closed on public, overlapping, and built-in destinations", () => { + assert.throws( + () => parsePluginRoutes(JSON.stringify([{ pathPrefix: "/x", access: "session", upstreamBase: "https://example.com" }])), + /private HTTP\(S\) origin/, + ); + assert.throws( + () => + parsePluginRoutes( + JSON.stringify([ + { pathPrefix: "/x", access: "session", upstreamBase: "http://plugin:8080" }, + { pathPrefix: "/x/y", access: "session", upstreamBase: "http://plugin:8080" }, + ]), + ), + /overlap/, + ); + assert.throws( + () => parsePluginRoutes(JSON.stringify([{ pathPrefix: "/admin/x", access: "session", upstreamBase: "http://plugin:8080" }])), + /built-in/, + ); + assert.throws( + () => + parsePluginRoutes( + JSON.stringify([{ pathPrefix: "/x", access: "session", upstreamBase: "http://plugin:8080", extra: true }]), + ), + /exactly/, + ); +}); + test("favicon: served unauthenticated as an SVG of the pirate-flag emoji", async () => { for (const path of ["/favicon.ico", "/favicon.svg"]) { const r = await fetch(`${base}${path}`); @@ -146,6 +180,73 @@ test("valid session: upstream receives ONLY the synthesized cookie, prefix strip assert.equal(body.headers["x-admin-actor"], undefined); }); +test("authenticated plugin route requires a session, strips its prefix, and mints portal identity", async () => { + const anonymousJson = await fetch(`${base}/programme/api/summary?q=1`, { redirect: "manual" }); + assert.equal(anonymousJson.status, 401); + const anonymousHtml = await fetch(`${base}/programme`, { headers: { accept: "text/html" }, redirect: "manual" }); + assert.equal(anonymousHtml.status, 302); + + const r = await fetch(`${base}/programme/api/summary?q=1`, { + headers: { + cookie: `${sessionCookie("U-programme")}; programme=EVIL`, + "x-portal-identity": "forged", + "x-as-principal": "EVIL", + "x-admin-actor": "EVIL@acme", + }, + }); + assert.equal(r.status, 200); + const body = (await r.json()) as { url: string; cookie: string | null; headers: Record }; + assert.equal(body.url, "/api/summary?q=1"); + assert.equal(body.cookie, null, "plugin routes do not receive legacy identity cookies"); + assert.equal(body.headers["x-as-principal"], undefined); + assert.equal(body.headers["x-admin-actor"], undefined); + assert.equal( + verifyPortalIdentity(body.headers["x-portal-identity"] ?? "", "router-test-identity-secret", Date.now())?.p, + "U-programme", + ); +}); + +test("signed-upstream plugin route is sessionless and forwards only the machine protocol allowlist", async () => { + const r = await fetch(`${base}/edge/v1/heartbeat?wait=1`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + authorization: "Bearer enrollment-token", + "x-qm-agent-id": "agent-1", + "x-qm-timestamp": "123", + "x-qm-nonce": "nonce-1", + "x-qm-body-digest": "sha256=abc", + "x-qm-signature": "v1=signed", + cookie: `${sessionCookie("U1")}; edge=EVIL`, + "x-portal-identity": "forged", + "x-as-principal": "EVIL", + "x-admin-actor": "EVIL@acme", + origin: "https://evil.example", + }, + body: "{}", + }); + assert.equal(r.status, 200); + const body = (await r.json()) as { url: string; cookie: string | null; headers: Record }; + assert.equal(body.url, "/heartbeat?wait=1"); + for (const name of [ + "content-type", + "content-length", + "accept", + "authorization", + "x-qm-agent-id", + "x-qm-timestamp", + "x-qm-nonce", + "x-qm-body-digest", + "x-qm-signature", + ]) { + assert.ok(body.headers[name], `${name} must be forwarded`); + } + for (const name of ["cookie", "x-portal-identity", "x-as-principal", "x-admin-actor", "origin"]) { + assert.equal(body.headers[name], undefined, `${name} must be dropped`); + } +}); + test("web-ui /app-edit drops x-frame-options so its own frame-ancestors CSP can allow the app origin", async () => { const editPage = await fetch(`${base}/app-edit?slug=demo`, { headers: { cookie: sessionCookie("U1") } }); assert.equal(editPage.status, 200); diff --git a/test/pi-dependency-security.test.ts b/test/pi-dependency-security.test.ts index 6ed6c76b..0e37ecd3 100644 --- a/test/pi-dependency-security.test.ts +++ b/test/pi-dependency-security.test.ts @@ -6,7 +6,7 @@ import test from "node:test"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; const piCodingAgentTarball = - "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.2/earendil-works-pi-coding-agent-0.82.0-qm-security.2.tgz"; + "https://github.com/TrueKrishna/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz"; function installedVersion(path: string): string { const manifestUrl = new URL(`../node_modules/${path}/package.json`, import.meta.url); @@ -45,10 +45,11 @@ test("Pi and MCP security overrides are materialized by the root lockfile", () = assert.equal(pi?.resolved, piCodingAgentTarball); assert.equal(pi?.hasShrinkwrap, true); - assert.deepEqual(lockedVersions(packages, "brace-expansion"), ["5.0.8"]); + assert.deepEqual(lockedVersions(packages, "brace-expansion"), ["5.0.9"]); assert.deepEqual(lockedVersions(packages, "protobufjs"), ["7.6.5"]); assert.deepEqual(lockedVersions(packages, "@hono/node-server"), ["2.0.10"]); - assert.equal(dependencyVersion(minimatchManifest, "brace-expansion"), "5.0.8"); + assert.equal(dependencyVersion(minimatchManifest, "brace-expansion"), "5.0.9"); + assert.equal(dependencyVersion(piManifest, "undici"), "8.10.0"); assert.equal(dependencyVersion(piManifest, "protobufjs"), "7.6.5"); assert.equal(installedVersion("@hono/node-server"), "2.0.10"); assert.match(