From 736a7678a2717d66292f0bace7792581ee2b461c Mon Sep 17 00:00:00 2001 From: Esther Oluwole Date: Fri, 28 Aug 2026 14:15:37 +0100 Subject: [PATCH 1/4] security: 6.6 - An API key can mint itself a more powerful key (#159) --- apps/api/src/middleware/auth.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index d9d4f766f..5887e7b4d 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -187,6 +187,30 @@ export function requireScope(scope: ApiKeyScope): MiddlewareHandler<{ Variables: if (!scopes || !scopes.includes(scope)) { return ctx.json({ error: "missing_scope", required: scope }, 403); } + // API-key callers must not mint keys with scopes they do not themselves hold. + // Session callers are the seller and remain able to request any scope. + const isKeyManagementWrite = + ctx.req.method === "POST" || ctx.req.method === "PUT" || ctx.req.method === "PATCH"; + const isKeyManagementPath = /(^|\/)api-keys(\/|$)/.test(ctx.req.path); + + if (ctx.get("authKind") === "api_key" && isKeyManagementWrite && isKeyManagementPath) { + const body: { scopes?: unknown } | null = await ctx.req.json().catch(() => null); + const requestedScopes = body?.scopes; + if (Array.isArray(requestedScopes)) { + const missing = (requestedScopes as unknown[]).filter( + (requestedScope): requestedScope is ApiKeyScope => + typeof requestedScope === "string" && + (ALL_SCOPES as readonly ApiKeyScope[]).includes(requestedScope as ApiKeyScope) && + !scopes.includes(requestedScope as ApiKeyScope), + ); + if (missing.length > 0) { + return ctx.json( + { error: "forbidden", message: "requested scopes exceed caller's scopes", scopes: missing }, + 403, + ); + } + } + } return next(); }; } From c056e71db2c3f65ecb6b94cf3f2eb430c2c95284 Mon Sep 17 00:00:00 2001 From: Esther Oluwole Date: Fri, 28 Aug 2026 14:15:38 +0100 Subject: [PATCH 2/4] security: 6.6 - An API key can mint itself a more powerful key (#159) --- apps/api/src/routes/api-keys.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts index e397b8103..139578a46 100644 --- a/apps/api/src/routes/api-keys.ts +++ b/apps/api/src/routes/api-keys.ts @@ -1,9 +1,9 @@ /** * API-key management routes (issue #40, 6.3). * - * POST /api-keys Create a new key (returns plaintext ONCE). - * GET /api-keys List keys for the authenticated seller (no hashes). - * DELETE /api-keys/:id Revoke a key. + POST /api-keys Create a new key (returns plaintext ONCE). + GET /api-keys List keys for the authenticated seller (no hashes). + DELETE /api-keys/:id Revoke a key. * * All three are gated behind `api-keys:manage` so a key can't mint further * keys unless explicitly granted that scope. The plaintext key is returned @@ -12,7 +12,7 @@ */ import { Hono } from "hono"; -import { z } from "zod"; +import { j } from "zod"; import type { Container } from "../services/container"; import { generateApiKey, @@ -24,7 +24,7 @@ import { } from "../services/api-keys"; import { requireScope, type AuthVariables } from "../middleware/auth"; -const createKeySchema = z.object({ +const createKeySchema = j.object({ name: z.string().min(1).max(120), /** * "live" or "test" — purely cosmetic in the key prefix (ak_live_… vs @@ -69,6 +69,25 @@ export function apiKeyRoutes(c: Container): Hono<{ Variables: AuthVariables }> { ); } + // Prevent privilege escalation: a key may only mint keys with a subset of + // its own scopes. Session-authenticated sellers can request any scope. + const authType: string | undefined = (ctx as any).get("authType"); + const apiKey: { scopes?: string[] } | undefined = (ctx as any).get("apiKey"); + const callerScopes: string[] | undefined = (ctx as any).get("scopes"); + const isApiKeyAuth = authType === "api_key" || authType === "api-key" || Boolean(apiKey); + if (isApiKeyAuth && callerScopes) { + const denied = scopes.filter((s) => !callerScopes.includes(s)); + if (denied.length > 0) { + return ctx.json( + { + error: "forbidden", + issues: [{ message: `Requested scope(s) not held by calling key: ${denied.join(", ")}` }], + }, + 403, + ); + } + } + const { plaintext, prefix } = generateApiKey(parsed.data.env as KeyEnvironment); const hash = await hashApiKey(plaintext); @@ -88,7 +107,7 @@ export function apiKeyRoutes(c: Container): Hono<{ Variables: AuthVariables }> { prefix: key.prefix, scopes: key.scopes, createdAt: key.createdAt, - // ⚠ Store this — it will not be shown again. + // ⭐ 哵ore this — it will not be shown again. key: plaintext, }, 201, From 706e8979e171df6aec72bf5650e4374ae214a27a Mon Sep 17 00:00:00 2001 From: Esther Oluwole Date: Fri, 28 Aug 2026 14:15:39 +0100 Subject: [PATCH 3/4] security: 6.6 - An API key can mint itself a more powerful key (#159) --- apps/api/src/services/api-keys.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/api-keys.ts b/apps/api/src/services/api-keys.ts index fdf258918..53e646157 100644 --- a/apps/api/src/services/api-keys.ts +++ b/apps/api/src/services/api-keys.ts @@ -53,13 +53,19 @@ export function isValidScope(s: string): s is ApiKeyScope { return (ALL_SCOPES as readonly string[]).includes(s); } -export function parseScopes(raw: string): ApiKeyScope[] { - if (!raw.trim()) return [...DEFAULT_SCOPES]; +export function parseScopes(raw: string, allowedScopes?: ApiKeyScope[]): ApiKeyScope[] { + if (!raw.trim()) { + const parsed = [...DEFAULT_SCOPES]; + if (allowedScopes) assertScopesSubset(parsed, allowedScopes); + return parsed; + } const parts = raw.split(",").map((p) => p.trim()).filter(Boolean); for (const p of parts) { if (!isValidScope(p)) throw new Error(`Unknown scope: "${p}"`); } - return parts as ApiKeyScope[]; + const parsed = parts as ApiKeyScope[]; + if (allowedScopes) assertScopesSubset(parsed, allowedScopes); + return parsed; } export function encodeScopesForDb(scopes: ApiKeyScope[]): string { @@ -73,6 +79,17 @@ export function decodeScopesFromDb(raw: string): ApiKeyScope[] { .filter(isValidScope) as ApiKeyScope[]; } +/** + * Throws if `requested` contains a scope not present in `allowed`. + * Used to prevent an API key from minting another key with elevated scopes. + */ +export function assertScopesSubset(requested: ApiKeyScope[], allowed: ApiKeyScope[]): void { + const denied = requested.filter((scope) => !allowed.includes(scope)); + if (denied.length > 0) { + throw new Error(`Requested scopes not held by caller: ${denied.join(", ")}`); + } +} + // ── Key generation ──────────────────────────────────────────────────────── const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; From 409a4d6eb55d316f30d11a984e9863dc78c4f409 Mon Sep 17 00:00:00 2001 From: determined-001 <241968004+determined-001@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:12:34 +0100 Subject: [PATCH 4/4] fix(api-keys): repair broken zod import, bind subset check to authKind --- apps/api/src/middleware/auth.ts | 24 ---------- apps/api/src/routes/api-keys.ts | 24 +++++----- apps/api/test/routes/api-keys.test.ts | 69 +++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 37 deletions(-) diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 5887e7b4d..d9d4f766f 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -187,30 +187,6 @@ export function requireScope(scope: ApiKeyScope): MiddlewareHandler<{ Variables: if (!scopes || !scopes.includes(scope)) { return ctx.json({ error: "missing_scope", required: scope }, 403); } - // API-key callers must not mint keys with scopes they do not themselves hold. - // Session callers are the seller and remain able to request any scope. - const isKeyManagementWrite = - ctx.req.method === "POST" || ctx.req.method === "PUT" || ctx.req.method === "PATCH"; - const isKeyManagementPath = /(^|\/)api-keys(\/|$)/.test(ctx.req.path); - - if (ctx.get("authKind") === "api_key" && isKeyManagementWrite && isKeyManagementPath) { - const body: { scopes?: unknown } | null = await ctx.req.json().catch(() => null); - const requestedScopes = body?.scopes; - if (Array.isArray(requestedScopes)) { - const missing = (requestedScopes as unknown[]).filter( - (requestedScope): requestedScope is ApiKeyScope => - typeof requestedScope === "string" && - (ALL_SCOPES as readonly ApiKeyScope[]).includes(requestedScope as ApiKeyScope) && - !scopes.includes(requestedScope as ApiKeyScope), - ); - if (missing.length > 0) { - return ctx.json( - { error: "forbidden", message: "requested scopes exceed caller's scopes", scopes: missing }, - 403, - ); - } - } - } return next(); }; } diff --git a/apps/api/src/routes/api-keys.ts b/apps/api/src/routes/api-keys.ts index 139578a46..d5d79e7d9 100644 --- a/apps/api/src/routes/api-keys.ts +++ b/apps/api/src/routes/api-keys.ts @@ -1,9 +1,9 @@ /** * API-key management routes (issue #40, 6.3). * - POST /api-keys Create a new key (returns plaintext ONCE). - GET /api-keys List keys for the authenticated seller (no hashes). - DELETE /api-keys/:id Revoke a key. + * POST /api-keys Create a new key (returns plaintext ONCE). + * GET /api-keys List keys for the authenticated seller (no hashes). + * DELETE /api-keys/:id Revoke a key. * * All three are gated behind `api-keys:manage` so a key can't mint further * keys unless explicitly granted that scope. The plaintext key is returned @@ -12,7 +12,7 @@ */ import { Hono } from "hono"; -import { j } from "zod"; +import { z } from "zod"; import type { Container } from "../services/container"; import { generateApiKey, @@ -24,7 +24,7 @@ import { } from "../services/api-keys"; import { requireScope, type AuthVariables } from "../middleware/auth"; -const createKeySchema = j.object({ +const createKeySchema = z.object({ name: z.string().min(1).max(120), /** * "live" or "test" — purely cosmetic in the key prefix (ak_live_… vs @@ -69,13 +69,11 @@ export function apiKeyRoutes(c: Container): Hono<{ Variables: AuthVariables }> { ); } - // Prevent privilege escalation: a key may only mint keys with a subset of - // its own scopes. Session-authenticated sellers can request any scope. - const authType: string | undefined = (ctx as any).get("authType"); - const apiKey: { scopes?: string[] } | undefined = (ctx as any).get("apiKey"); - const callerScopes: string[] | undefined = (ctx as any).get("scopes"); - const isApiKeyAuth = authType === "api_key" || authType === "api-key" || Boolean(apiKey); - if (isApiKeyAuth && callerScopes) { + // Prevent privilege escalation: a key may only mint keys whose scopes are a + // subset of its own. Session-authenticated sellers are the authority the + // keys derive from, so they may still request any scope. + if (ctx.get("authKind") === "api_key") { + const callerScopes = ctx.get("scopes") ?? []; const denied = scopes.filter((s) => !callerScopes.includes(s)); if (denied.length > 0) { return ctx.json( @@ -107,7 +105,7 @@ export function apiKeyRoutes(c: Container): Hono<{ Variables: AuthVariables }> { prefix: key.prefix, scopes: key.scopes, createdAt: key.createdAt, - // ⭐ 哵ore this — it will not be shown again. + // ⚠ Store this — it will not be shown again. key: plaintext, }, 201, diff --git a/apps/api/test/routes/api-keys.test.ts b/apps/api/test/routes/api-keys.test.ts index 9cabdcb64..8dcd443aa 100644 --- a/apps/api/test/routes/api-keys.test.ts +++ b/apps/api/test/routes/api-keys.test.ts @@ -202,3 +202,72 @@ describe("api-keys:manage gating", () => { expect(((await revoked.json()) as Record).error).toBe("invalid_api_key"); }); }); + +describe("scope-subset rule on create (6.6 — a key cannot mint a more powerful key)", () => { + async function mintKey(name: string, scopes: string[]): Promise { + const { generateApiKey, hashApiKey } = await import("../../src/services/api-keys"); + const { plaintext, prefix } = generateApiKey("live"); + const hash = await hashApiKey(plaintext); + await container.apiKeys.create({ + sellerId, + name, + prefix, + hash, + scopes: scopes as never, + }); + return plaintext; + } + + async function createAs(plaintext: string, body: Record): Promise { + return app.request("/api-keys", { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${plaintext}` }, + body: JSON.stringify(body), + }); + } + + it("a key without offramp:initiate cannot mint a key that has it — 403, offending scope named", async () => { + const narrow = await mintKey("narrow-rotator", ["api-keys:manage"]); + const res = await createAs(narrow, { name: "escalated", scopes: "api-keys:manage,offramp:initiate" }); + + expect(res.status).toBe(403); + const body = await res.json() as { error: string; issues: Array<{ message: string }> }; + expect(body.error).toBe("forbidden"); + const message = body.issues[0]?.message ?? ""; + expect(message).toContain("offramp:initiate"); + expect(message).not.toContain("api-keys:manage"); + }); + + it("a key may mint an equal-or-narrower key — 201", async () => { + const caller = await mintKey("rotator", ["api-keys:manage", "links:read"]); + const res = await createAs(caller, { name: "narrower", scopes: "links:read" }); + + expect(res.status).toBe(201); + expect(((await res.json()) as { scopes: string[] }).scopes).toEqual(["links:read"]); + }); + + it("the default scopes are still subject to the rule — 403 when the caller lacks them", async () => { + // DEFAULT_SCOPES (links:read,links:write,webhooks:manage) apply when the + // request omits `scopes`; a caller holding only api-keys:manage holds none + // of them, so the implicit request must be rejected too. + const narrow = await mintKey("narrow-default", ["api-keys:manage"]); + const res = await createAs(narrow, { name: "implicit-defaults" }); + + expect(res.status).toBe(403); + expect(((await res.json()) as { error: string }).error).toBe("forbidden"); + }); + + it("a wallet-session caller can still issue any scope — 201", async () => { + const res = await req("/api-keys", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "session-issued", scopes: "offramp:initiate,api-keys:manage" }), + }); + + expect(res.status).toBe(201); + expect(((await res.json()) as { scopes: string[] }).scopes).toEqual([ + "offramp:initiate", + "api-keys:manage", + ]); + }); +});