diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87b04c415..6faad1257 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,8 +149,31 @@ jobs: - name: install run: npm ci --legacy-peer-deps=false --install-links=false - - name: release verify - run: npm run release:verify + - name: release typecheck + run: npm run typecheck + + - name: release tests + shell: bash + run: | + set -euo pipefail + log="$RUNNER_TEMP/noema-release-tests.log" + if npm run test -- --reporter=dot >"$log" 2>&1; then + exit 0 + fi + tail -c 32768 "$log" | tail -n 160 + exit 1 + + - name: release security scan + run: npm run security:scan + + - name: release KPI verification + run: npm run kpi:verify + + - name: release acquisition manifest + run: npm run acquisition:manifest + + - name: release acquisition integrity + run: npm run acquisition:integrity - name: refuse pull-request base drift after verification if: github.event_name == 'pull_request' diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 4b0f71eb7..dadc4abc9 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -19,6 +19,7 @@ const TRUSTED_GITHUB_API_ORIGIN = "https://api.github.com"; const trustedGithubApiBasePattern = /^https:\/\/api\.github\.com(?::443)?\/?$/; const trustedTracePattern = /^[A-Za-z0-9._:-]+$/; const jwtSegmentPattern = /^[A-Za-z0-9_-]+$/; +const positiveDecimalPattern = /^[1-9][0-9]*$/; const MAX_TRACE_LENGTH = 128; const MAX_AUTHORIZATION_HEADER_LENGTH = 16_384; const MAX_JWT_HEADER_SEGMENT_LENGTH = 2_048; @@ -29,7 +30,11 @@ const MAX_EXCHANGE_JSON_BODY_BYTES = 8_192; type EgressFailure = { hint: string; outcome: "misconfigured" | "policy_unavailable"; - policy: "github-cloud-exact-origin" | "credential-fetch-no-redirect"; + policy: + | "github-cloud-exact-origin" + | "github-app-id-canonical" + | "github-app-installation-id-canonical" + | "credential-fetch-no-redirect"; }; type ExchangeBodyFailure = { @@ -78,18 +83,26 @@ export function isTrustedGithubApiBase(value: unknown): value is string { } } +function isCanonicalPositiveSafeInteger(value: string): boolean { + if (!positiveDecimalPattern.test(value)) return false; + const numericValue = Number(value); + return Number.isSafeInteger(numericValue) && String(numericValue) === value; +} + /** * Accept only a compact, bounded JWT envelope before any decoding or credential use. - * Missing and non-Bearer authorization values are delegated to the normal API error path. + * Missing and non-Bearer authorization values are delegated to the normal API error path; + * a value using the Bearer scheme must itself be one exact compact JWT envelope. * @param value Authorization header value observed at the request edge, or null when absent. * @returns False only when a Bearer JWT envelope is structurally invalid or exceeds limits. */ export function isBoundedOidcBearer(value: string | null): boolean { if (value === null) return true; + if (!/^Bearer(?:\s|$)/i.test(value)) return true; + if (value.length > MAX_AUTHORIZATION_HEADER_LENGTH) return false; const match = value.match(/^Bearer\s+(\S+)$/i); - if (!match) return true; - if (value.length > MAX_AUTHORIZATION_HEADER_LENGTH) return false; + if (!match) return false; const segments = match[1].split("."); if (segments.length !== 3) return false; @@ -167,6 +180,23 @@ function hasDuplicateTargetRepositoryKey(body: Uint8Array): boolean { return false; } +function cancelRequestBodyBestEffort(request: Request, reason: string): void { + try { + if (request.body === null) return; + void request.body.cancel(reason).catch(() => undefined); + } catch { + // Cancellation is best-effort after the request has already been rejected. + } +} + +function cancelReaderBestEffort(reader: ReadableStreamDefaultReader, reason: string): void { + try { + void reader.cancel(reason).catch(() => undefined); + } catch { + // Cancellation is best-effort after the request has already been rejected. + } +} + /** * Consume and rebuild only JSON POST bodies within the exchange API's byte budget. * Streaming consumption prevents a chunked request from bypassing Content-Length checks. @@ -186,6 +216,7 @@ export async function boundExchangeJsonBody(request: Request): Promise MAX_EXCHANGE_JSON_BODY_BYTES ) { + cancelRequestBodyBestEffort(request, "Noema exchange JSON body exceeds declared byte limit"); return { ok: false, failure: { reason: "too_large", status: 413 }, @@ -213,11 +245,7 @@ export async function boundExchangeJsonBody(request: Request): Promise MAX_EXCHANGE_JSON_BODY_BYTES) { - try { - await reader.cancel("Noema exchange JSON body exceeds byte limit"); - } catch { - // Cancellation is best-effort after the request has already been rejected. - } + cancelReaderBestEffort(reader, "Noema exchange JSON body exceeds byte limit"); return { ok: false, failure: { reason: "too_large", status: 413 }, @@ -226,6 +254,7 @@ export async function boundExchangeJsonBody(request: Request): Promise = { const trustedHeaderValuePattern = /^[A-Za-z0-9._:-]+$/; const clientIdentifierPattern = /^[A-Za-z0-9.:%_,-]+$/; const exactWorkflowSourceShaPattern = /^[0-9a-f]{40}$/; +const githubInstallationTokenExpiryPattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/; +const expectedRepositoryOwnerId = "295022177"; +const expectedRepositoryIds = new Map([ + ["ContextualWisdomLab/noema", "1285107801"], + ["ContextualWisdomLab/.github", "1274066402"], +]); const maxTrustedHeaderLength = 128; +const maxInstallationTokenLifetimeMs = 65 * 60_000; function jsonResponse(body: StandardErrorResponse | StandardSuccessResponse, status = 200): Response { return new Response(JSON.stringify(body), { @@ -191,6 +200,18 @@ function valueType(value: unknown): string { return typeof value; } +function canonicalGithubInstallationTokenExpiry(value: string, parsedMs: number): boolean { + const match = value.match(githubInstallationTokenExpiryPattern); + if (!match) return false; + const milliseconds = (match[7] ?? "").padEnd(3, "0"); + const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}.${milliseconds}Z`; + try { + return new Date(parsedMs).toISOString() === normalized; + } catch { + return false; + } +} + function requestClientKey(request: Request, route: string): string { const client = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") @@ -425,6 +446,20 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; if (!audiences.includes(env.ALLOWED_AUDIENCE)) throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC audience is not allowed"); if (payload.repository_owner !== env.ALLOWED_REPOSITORY_OWNER) throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner is not allowed"); + if ( + payload.repository_owner_id !== undefined + && payload.repository_owner_id !== expectedRepositoryOwnerId + ) { + throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner identity is not allowed"); + } + const expectedRepositoryId = payload.repository ? expectedRepositoryIds.get(payload.repository) : undefined; + if ( + expectedRepositoryId !== undefined + && payload.repository_id !== undefined + && payload.repository_id !== expectedRepositoryId + ) { + throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository identity is not allowed"); + } const workflowRef = payload.job_workflow_ref || payload.workflow_ref || ""; if (workflowRef !== env.ALLOWED_WORKFLOW_REF_PREFIX) { @@ -451,10 +486,19 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise { match_policy: "exact-ref-and-source-sha" }, ); } + if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || !Number.isFinite(payload.nbf))) { + throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC not-before claim is invalid"); + } if (typeof payload.nbf === "number" && payload.nbf > now + 30) { throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC token is not valid yet"); } - if (typeof payload.exp !== "number" || payload.exp < now - 30) { + if (payload.iat !== undefined && (typeof payload.iat !== "number" || !Number.isFinite(payload.iat))) { + throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC issued-at claim is invalid"); + } + if (typeof payload.iat === "number" && payload.iat > now + 30) { + throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC token was issued in the future"); + } + if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp) || payload.exp < now - 30) { throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC token is expired"); } @@ -501,8 +545,8 @@ type GitHubJsonRequestInit = RequestInit & { headers: Record; }; -async function githubJson(path: string, init: GitHubJsonRequestInit, env: Env): Promise { - const response = await fetch(`${env.GITHUB_API_BASE}${path}`, { +async function githubJson(path: string, init: GitHubJsonRequestInit, env: Env): Promise> { + const response = await fetch(new URL(path, env.GITHUB_API_BASE), { ...init, headers: { accept: "application/vnd.github+json", @@ -520,11 +564,30 @@ async function githubJson(path: string, init: GitHubJsonRequestInit, env: Env): } throw new ApiError("ERR_GITHUB_API", response.status >= 400 ? 400 : 500, "GitHub API request failed"); } - return response.json(); + let value: unknown; + try { + value = await response.json(); + } catch { + throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned malformed JSON"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid JSON shape"); + } + return value as Record; } async function resolveInstallationId(appJwt: string, repository: string, env: Env): Promise { - if (env.GITHUB_APP_INSTALLATION_ID) return env.GITHUB_APP_INSTALLATION_ID; + if (env.GITHUB_APP_INSTALLATION_ID) { + const configuredInstallationId = env.GITHUB_APP_INSTALLATION_ID; + if (!/^[1-9]\d*$/.test(configuredInstallationId)) { + throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id configuration is invalid"); + } + const numericInstallationId = Number(configuredInstallationId); + if (!Number.isSafeInteger(numericInstallationId) || String(numericInstallationId) !== configuredInstallationId) { + throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id configuration is invalid"); + } + return configuredInstallationId; + } const now = Date.now(); const cacheKey = `${env.GITHUB_API_BASE}:${env.GITHUB_APP_ID}:${repository}`; const cached = installationIdCache.get(cacheKey); @@ -538,7 +601,16 @@ async function resolveInstallationId(appJwt: string, repository: string, env: En const installation = await githubJson(`/repos/${repository}/installation`, { headers: { authorization: `Bearer ${appJwt}` }, }, env); - if (!installation.id) throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id was not found"); + if (installation.id === undefined || installation.id === null) { + throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id was not found"); + } + if ( + typeof installation.id !== "number" + || !Number.isSafeInteger(installation.id) + || installation.id <= 0 + ) { + throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid installation response"); + } const installationId = String(installation.id); installationIdCache.set(cacheKey, { value: installationId, @@ -555,21 +627,44 @@ async function createInstallationToken(repository: string, env: Env): Promise nowMs + maxInstallationTokenLifetimeMs) { + throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned implausible installation-token expiry"); + } return { - token: String(token.token), - expires_at: String(token.expires_at), + token: token.token, + expires_at: token.expires_at, }; } diff --git a/src/outbound-fetch-policy.ts b/src/outbound-fetch-policy.ts index d30096248..b6c7763dc 100644 --- a/src/outbound-fetch-policy.ts +++ b/src/outbound-fetch-policy.ts @@ -20,7 +20,7 @@ type FetchInstallation = { wrapped: FetchLike; }; -type BlockReason = "destination" | "request-policy" | "redirect" | "response-size" | "timeout"; +type BlockReason = "destination" | "request-policy" | "redirect" | "response-size" | "response-read" | "timeout" | "transport"; type GitHubApiOperation = | "repository-installation" @@ -42,7 +42,7 @@ const githubRepositoryInstallationPathPattern = new RegExp( ); const githubAppInstallationsPathPattern = /^\/app\/installations$/; const githubInstallationTokenPathPattern = - /^\/app\/installations\/[1-9][0-9]*\/access_tokens$/; + /^\/app\/installations\/([1-9][0-9]*)\/access_tokens$/; const githubRepositoryNamePattern = /^(?!\.{1,2}$)[A-Za-z0-9_.-]+$/; const installations = new WeakMap(); @@ -151,6 +151,14 @@ function boundedOutboundSignal( return AbortSignal.any(signals); } +function ignoreCancellationBestEffort(cancel: () => Promise): void { + try { + void cancel().catch(() => undefined); + } catch { + // Cleanup is best-effort after the response has already crossed a fail-closed rejection boundary. + } +} + async function boundedOutboundResponse(response: Response): Promise { const declaredLength = response.headers.get("content-length"); if ( @@ -159,11 +167,9 @@ async function boundedOutboundResponse(response: Response): Promise { && Number(declaredLength) > MAX_OUTBOUND_RESPONSE_BYTES ) { if (response.body !== null) { - try { - await response.body.cancel("Noema outbound response exceeds byte limit"); - } catch { - // Cancellation is best-effort after the response has already been rejected. - } + ignoreCancellationBestEffort(() => response.body!.cancel( + "Noema outbound response exceeds byte limit", + )); } return blockedResponse("response-size"); } @@ -173,19 +179,24 @@ async function boundedOutboundResponse(response: Response): Promise { const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let totalBytes = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - totalBytes += value.byteLength; - if (totalBytes > MAX_OUTBOUND_RESPONSE_BYTES) { - try { - await reader.cancel("Noema outbound response exceeds byte limit"); - } catch { - // Cancellation is best-effort after the response has already been rejected. + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_OUTBOUND_RESPONSE_BYTES) { + ignoreCancellationBestEffort(() => reader.cancel( + "Noema outbound response exceeds byte limit", + )); + return blockedResponse("response-size"); } - return blockedResponse("response-size"); + chunks.push(value); } - chunks.push(value); + } catch { + ignoreCancellationBestEffort(() => reader.cancel( + "Noema outbound response body could not be read", + )); + return blockedResponse("response-read"); } const boundedBody = new Uint8Array(totalBytes); @@ -203,6 +214,17 @@ async function boundedOutboundResponse(response: Response): Promise { }); } +function canonicalInstallationIdFromTokenPath(url: URL): string | undefined { + const match = url.pathname.match(githubInstallationTokenPathPattern); + if (!match) return undefined; + const installationId = match[1]; + const numericId = Number(installationId); + if (!Number.isSafeInteger(numericId) || String(numericId) !== installationId) { + return undefined; + } + return installationId; +} + function githubApiOperation(url: URL): GitHubApiOperation | undefined { if (url.search !== "") return undefined; if (githubRepositoryInstallationPathPattern.test(url.pathname)) { @@ -211,7 +233,7 @@ function githubApiOperation(url: URL): GitHubApiOperation | undefined { if (githubAppInstallationsPathPattern.test(url.pathname)) { return "app-installations"; } - if (githubInstallationTokenPathPattern.test(url.pathname)) { + if (canonicalInstallationIdFromTokenPath(url) !== undefined) { return "installation-token"; } return undefined; @@ -296,9 +318,9 @@ export function isTrustedCredentialEgressRequest( } /** - * Wraps fetch with fail-closed credential destination, request-role, redirect, response-size, and timeout enforcement. + * Wraps fetch with fail-closed credential destination, request-role, redirect, response-size, timeout, and credential-bearing transport enforcement. * @param rawFetch Trusted underlying fetch implementation that performs only requests admitted by the wrapper. - * @returns A fetch-compatible function that blocks redirects and timeout violations instead of leaking credentials. + * @returns A fetch-compatible function that blocks credential-bearing transport failures and policy violations while preserving caller cancellation and ordinary unauthenticated transport errors. */ export function createFailClosedFetch(rawFetch: FetchLike): FetchLike { return async (input, init) => { @@ -327,6 +349,11 @@ export function createFailClosedFetch(rawFetch: FetchLike): FetchLike { signal, }); if (response.redirected || (response.status >= 300 && response.status < 400)) { + if (response.body !== null) { + ignoreCancellationBestEffort(() => response.body!.cancel( + "Noema outbound redirect response is not accepted", + )); + } return blockedResponse("redirect"); } return await boundedOutboundResponse(response); @@ -334,6 +361,12 @@ export function createFailClosedFetch(rawFetch: FetchLike): FetchLike { if (signal.aborted && signal.reason === timeoutReason) { return blockedResponse("timeout"); } + if (signal.aborted) { + throw error; + } + if (outboundHeaders(input, init).has("authorization")) { + return blockedResponse("transport"); + } throw error; } finally { clearTimeout(timeoutHandle); diff --git a/src/runtime-readiness.ts b/src/runtime-readiness.ts index 62e53114b..0ad58ee73 100644 --- a/src/runtime-readiness.ts +++ b/src/runtime-readiness.ts @@ -83,18 +83,31 @@ function isTrustedWorkflowRepository(value: string, owner: string): boolean { return new RegExp(`^${escapedOwner}/[A-Za-z0-9_.-]{1,100}$`).test(value); } -function isExactWorkflowRef(value: string, repository: string): boolean { +function workflowRefName(value: string, repository: string): string | undefined { const escapedRepository = escapeRegularExpression(repository); const workflowRefPattern = new RegExp( `^${escapedRepository}/\\.github/workflows/[A-Za-z0-9_.-]{1,100}\\.ya?ml@(.+)$`, ); - const match = workflowRefPattern.exec(value); - if (!match) return false; + return workflowRefPattern.exec(value)?.[1]; +} - const refName = match[1]; +function isExactWorkflowRef(value: string, repository: string): boolean { + const refName = workflowRefName(value, repository); + if (!refName) return false; return exactCommitPattern.test(refName) || trustedNamedRefPattern.test(refName); } +function immutableWorkflowCommit(value: string, repository: string): string | undefined { + const refName = workflowRefName(value, repository); + return refName && exactCommitPattern.test(refName) ? refName.toLowerCase() : undefined; +} + +function isCanonicalPositiveSafeInteger(value: string | undefined): boolean { + if (!positiveDecimalPattern.test(value ?? "")) return false; + const numericValue = Number(value); + return Number.isSafeInteger(numericValue) && String(numericValue) === value; +} + function isDurableObjectNamespace(value: unknown): value is DurableObjectNamespace { if (!value || (typeof value !== "object" && typeof value !== "function")) { return false; @@ -138,13 +151,13 @@ function cachedPrivateKeyImportability(env: RuntimeReadinessEnv): Promise { expect(workflow).not.toContain('test "$live_base_sha" = "$NOEMA_PR_BASE_SHA"'); }); + it("keeps each release verifier visible as its own failing CI boundary with bounded test diagnostics", () => { + const workflow = readWorkflow(workflowPaths[0]); + const releaseSteps = [ + ["- name: release typecheck", "run: npm run typecheck"], + ["- name: release tests", 'log="$RUNNER_TEMP/noema-release-tests.log"'], + ["- name: release security scan", "run: npm run security:scan"], + ["- name: release KPI verification", "run: npm run kpi:verify"], + ["- name: release acquisition manifest", "run: npm run acquisition:manifest"], + ["- name: release acquisition integrity", "run: npm run acquisition:integrity"], + ] as const; + + let previousIndex = workflow.indexOf("- name: install"); + expect(previousIndex).toBeGreaterThanOrEqual(0); + for (const [stepName, command] of releaseSteps) { + const stepIndex = workflow.indexOf(stepName); + expect(stepIndex).toBeGreaterThan(previousIndex); + expect(workflow.slice(stepIndex)).toContain(command); + previousIndex = stepIndex; + } + expect(workflow).toContain('npm run test -- --reporter=dot >"$log" 2>&1'); + expect(workflow).toContain('tail -c 32768 "$log" | tail -n 160'); + expect(workflow).not.toContain("run: npm run test -- --reporter=dot"); + expect(workflow).not.toContain("run: npm run release:verify"); + }); + it("binds reviewer CI to the immutable pull-request head before reviewer dependency installation", () => { expectExactHeadContract( readWorkflow(workflowPaths[1]), "- name: install (hash-pinned dependencies)", ); }); -}); \ No newline at end of file +}); diff --git a/test/exchange-body-cleanup-liveness.test.ts b/test/exchange-body-cleanup-liveness.test.ts new file mode 100644 index 000000000..2c8c7b2ca --- /dev/null +++ b/test/exchange-body-cleanup-liveness.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { boundExchangeJsonBody } from "../src/entrypoint"; + +function streamedJsonRequest(stream: ReadableStream): Request { + return new Request("https://noema.example/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: stream, + duplex: "half", + } as RequestInit & { duplex: "half" }); +} + +describe("exchange JSON body cleanup liveness", () => { + it("does not await a never-settling stream cancellation after the body is already oversized", async () => { + let observeCancel: (() => void) | undefined; + const cancelObserved = new Promise((resolve) => { + observeCancel = resolve; + }); + let emitted = false; + const request = streamedJsonRequest(new ReadableStream({ + pull(controller) { + if (emitted) return; + emitted = true; + controller.enqueue(new Uint8Array(8_193)); + }, + cancel() { + observeCancel?.(); + return new Promise(() => undefined); + }, + })); + + let settled = false; + const resultPromise = boundExchangeJsonBody(request).then((result) => { + settled = true; + return result; + }); + + await cancelObserved; + await Promise.resolve(); + await Promise.resolve(); + + expect(settled).toBe(true); + await expect(resultPromise).resolves.toEqual({ + ok: false, + failure: { reason: "too_large", status: 413 }, + }); + }); + + it("cleans up a request stream that fails while being read without replacing the unreadable rejection", async () => { + const request = streamedJsonRequest(new ReadableStream()); + const cancel = vi.fn(async () => undefined); + vi.spyOn(request.body!, "getReader").mockReturnValue({ + read: vi.fn(async () => { + throw new Error("synthetic exchange request read failure"); + }), + cancel, + } as unknown as ReadableStreamDefaultReader); + + await expect(boundExchangeJsonBody(request)).resolves.toEqual({ + ok: false, + failure: { reason: "unreadable", status: 400 }, + }); + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/exchange-body-cleanup-sync-throw.test.ts b/test/exchange-body-cleanup-sync-throw.test.ts new file mode 100644 index 000000000..ca6920801 --- /dev/null +++ b/test/exchange-body-cleanup-sync-throw.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { boundExchangeJsonBody } from "../src/entrypoint"; + +describe("exchange request-body cleanup defensive boundary", () => { + it("preserves an already-decided unsupported-media rejection if host cancellation throws synchronously", async () => { + const request = new Request("https://noema.example/exchange", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "ignored", + }); + if (request.body === null) throw new Error("expected request body"); + Object.defineProperty(request.body, "cancel", { + configurable: true, + value() { + throw new Error("synthetic host cancellation failure"); + }, + }); + + await expect(boundExchangeJsonBody(request)).resolves.toEqual({ + ok: false, + failure: { reason: "unsupported_media_type", status: 415 }, + }); + }); +}); diff --git a/test/exchange-body-early-rejection-cleanup.test.ts b/test/exchange-body-early-rejection-cleanup.test.ts new file mode 100644 index 000000000..63ee9c394 --- /dev/null +++ b/test/exchange-body-early-rejection-cleanup.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { boundExchangeJsonBody } from "../src/entrypoint"; + +function requestWithStream( + stream: ReadableStream, + headers: HeadersInit, +): Request { + return new Request("https://noema.example/exchange", { + method: "POST", + headers, + body: stream, + duplex: "half", + } as RequestInit & { duplex: "half" }); +} + +async function expectBoundedEarlyRejection( + request: Request, + expected: { reason: "too_large" | "unsupported_media_type"; status: 413 | 415 }, +): Promise { + const result = await Promise.race([ + boundExchangeJsonBody(request), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("early rejection waited for request-body cleanup")), 100); + }), + ]); + + expect(result).toEqual({ ok: false, failure: expected }); +} + +describe("exchange JSON body early-rejection cleanup", () => { + it("cancels a declared-oversized request body without awaiting cancellation", async () => { + let cancelObserved = false; + const request = requestWithStream( + new ReadableStream({ + cancel() { + cancelObserved = true; + return new Promise(() => undefined); + }, + }), + { + "content-type": "application/json", + "content-length": "8193", + }, + ); + + await expectBoundedEarlyRejection(request, { reason: "too_large", status: 413 }); + expect(cancelObserved).toBe(true); + }); + + it("keeps rejection authoritative when asynchronous body cancellation rejects", async () => { + let cancelObserved = false; + const request = requestWithStream( + new ReadableStream({ + cancel() { + cancelObserved = true; + return Promise.reject(new Error("cleanup rejected")); + }, + }), + { "content-type": "text/plain" }, + ); + + await expectBoundedEarlyRejection(request, { reason: "unsupported_media_type", status: 415 }); + await Promise.resolve(); + expect(cancelObserved).toBe(true); + }); + + it("returns the original POST unchanged when the runtime exposes no body stream", async () => { + const request = new Request("https://noema.example/exchange", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "8193", + }, + }); + + expect(request.body).toBeNull(); + await expect(boundExchangeJsonBody(request)).resolves.toEqual({ ok: true, request }); + }); + + it("keeps an already-decided rejection when the runtime body becomes unavailable before cleanup", async () => { + const admittedBody = new ReadableStream(); + let bodyReads = 0; + const request = { + method: "POST", + headers: new Headers({ "content-type": "text/plain" }), + get body() { + bodyReads += 1; + return bodyReads === 1 ? admittedBody : null; + }, + } as unknown as Request; + + await expectBoundedEarlyRejection(request, { reason: "unsupported_media_type", status: 415 }); + expect(bodyReads).toBe(2); + }); + + it("cancels an unsupported-media request body without awaiting cancellation", async () => { + let cancelObserved = false; + const request = requestWithStream( + new ReadableStream({ + cancel() { + cancelObserved = true; + return new Promise(() => undefined); + }, + }), + { "content-type": "text/plain" }, + ); + + await expectBoundedEarlyRejection(request, { reason: "unsupported_media_type", status: 415 }); + expect(cancelObserved).toBe(true); + }); +}); diff --git a/test/exchange-body-limit.test.ts b/test/exchange-body-limit.test.ts index 385e44f20..8b6461187 100644 --- a/test/exchange-body-limit.test.ts +++ b/test/exchange-body-limit.test.ts @@ -287,7 +287,7 @@ describe("exchange JSON body boundary", () => { }, body: '{"target_repository":"ContextualWisdomLab/noema"}', }), - { GITHUB_API_BASE: "https://example.com" } as Env, + { GITHUB_API_BASE: "https://example.com", GITHUB_APP_ID: "123456" } as Env, ); expect(response.status).toBe(503); @@ -296,4 +296,4 @@ describe("exchange JSON body boundary", () => { details: { policy: "github-cloud-exact-origin" }, }); }); -}); \ No newline at end of file +}); diff --git a/test/exchange-success-path-coverage.test.ts b/test/exchange-success-path-coverage.test.ts index 5df4dae02..383280f97 100644 --- a/test/exchange-success-path-coverage.test.ts +++ b/test/exchange-success-path-coverage.test.ts @@ -63,8 +63,13 @@ afterEach(() => { }); describe("exchange success-path coverage through the public worker", () => { - it("accepts workflow_ref-only claims without inventing an OIDC subject", async () => { + it.each([ + "https://api.github.com", + "https://api.github.com/", + "https://api.github.com:443/", + ])("accepts workflow_ref-only claims with GitHub API base %s", async (githubApiBase) => { const now = Math.floor(Date.now() / 1000); + const expiresAt = new Date(Date.now() + 60 * 60_000).toISOString(); const { token: oidcToken, jwk } = await createSignedJwt({ iss: env.ALLOWED_ISSUER, aud: env.ALLOWED_AUDIENCE, @@ -107,7 +112,7 @@ describe("exchange success-path coverage through the public worker", () => { if (url === "https://api.github.com/app/installations/12345/access_tokens") { return Response.json({ token: "ghs_exchange_success_token", - expires_at: "2030-01-01T00:00:00Z", + expires_at: expiresAt, }); } return new Response("not found", { status: 404 }); @@ -125,6 +130,7 @@ describe("exchange success-path coverage through the public worker", () => { }), { ...env, + GITHUB_API_BASE: githubApiBase, GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKey, }, ); @@ -136,7 +142,7 @@ describe("exchange success-path coverage through the public worker", () => { token: "ghs_exchange_success_token", repository: "ContextualWisdomLab/noema", workflow_ref: configuredRef, - token_expires_at: "2030-01-01T00:00:00Z", + token_expires_at: expiresAt, }, }); const logOutput = logSpy.mock.calls.flat().join("\n"); diff --git a/test/github-api-egress.test.ts b/test/github-api-egress.test.ts index 3463b4c3d..163d94083 100644 --- a/test/github-api-egress.test.ts +++ b/test/github-api-egress.test.ts @@ -129,7 +129,7 @@ describe("GitHub API egress policy", () => { method: "POST", headers: { "x-request-id": "egress-policy-test" }, }), - { GITHUB_API_BASE: rawBase } as Env, + { GITHUB_API_BASE: rawBase, GITHUB_APP_ID: "123456" } as Env, ); expect(response.status).toBe(503); @@ -159,7 +159,7 @@ describe("GitHub API egress policy", () => { method: "POST", headers: { "x-request-id": "redirect-policy-test" }, }), - { GITHUB_API_BASE: "https://api.github.com" } as Env, + { GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456" } as Env, ); expect(response.status).toBe(503); @@ -216,7 +216,7 @@ describe("GitHub API egress policy", () => { method: "POST", headers: { "cf-connecting-ip": "203.0.113.10" }, }), - { GITHUB_API_BASE: "https://api.github.com" } as Env, + { GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456" } as Env, ); expect(response.status).toBe(503); diff --git a/test/github-api-malformed-json.test.ts b/test/github-api-malformed-json.test.ts new file mode 100644 index 000000000..09a0f3167 --- /dev/null +++ b/test/github-api-malformed-json.test.ts @@ -0,0 +1,284 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import worker, { type Env } from "../src/index"; + +const configuredRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); + +const baseEnv: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "initialized-in-beforeAll", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +let oidcKeyPair: CryptoKeyPair; +let oidcPublicJwk: JsonWebKey; +let appPrivateKeyPem: string; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(bytes: ArrayBuffer): string { + return Buffer.from(bytes).toString("base64url"); +} + +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} + +async function generateRsaKeyPair(): Promise { + return crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); +} + +beforeAll(async () => { + oidcKeyPair = await generateRsaKeyPair(); + oidcPublicJwk = await crypto.subtle.exportKey("jwk", oidcKeyPair.publicKey); + const appKeyPair = await generateRsaKeyPair(); + appPrivateKeyPem = pemFromPkcs8( + await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function signedOidcToken() { + const kid = `github-json-${crypto.randomUUID()}`; + const now = Math.floor(Date.now() / 1000); + const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); + const payload = encodeSegment({ + iss: baseEnv.ALLOWED_ISSUER, + aud: baseEnv.ALLOWED_AUDIENCE, + repository_owner: baseEnv.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + oidcKeyPair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + return { + token: `${header}.${payload}.${encodeBytes(signature)}`, + jwk: { ...oidcPublicJwk, kid, kty: "RSA" }, + }; +} + +async function exchangeWith( + targetRepository: string, + env: Env, + githubHandler: (url: string) => Promise | Response, + clientIp: string, +): Promise { + const { token, jwk } = await signedOidcToken(); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + }); + } + if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { + return Response.json({ keys: [jwk] }); + } + return githubHandler(url); + }); + + return worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": clientIp, + }, + body: JSON.stringify({ target_repository: targetRepository }), + }), + { ...env, GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem }, + ); +} + +describe("GitHub API success-response parsing", () => { + it("classifies malformed installation JSON as an upstream GitHub API failure", async () => { + const targetRepository = "ContextualWisdomLab/malformed-installation-json"; + const response = await exchangeWith(targetRepository, baseEnv, (url) => { + if (url === `https://api.github.com/repos/${targetRepository}/installation`) { + return new Response("{", { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("unexpected GitHub request", { status: 500 }); + }, "203.0.113.240"); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API returned malformed JSON", + }); + }); + + it("classifies malformed installation-token JSON as an upstream GitHub API failure", async () => { + const response = await exchangeWith( + "ContextualWisdomLab/malformed-token-json", + { ...baseEnv, GITHUB_APP_INSTALLATION_ID: "92345" }, + (url) => { + if (url === "https://api.github.com/app/installations/92345/access_tokens") { + return new Response("{", { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("unexpected GitHub request", { status: 500 }); + }, + "203.0.113.241", + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API returned malformed JSON", + }); + }); + + it.each([ + ["null", "203.0.113.242"], + ["[]", "203.0.113.243"], + ["\"unexpected\"", "203.0.113.244"], + ])("classifies non-object installation JSON %s as an upstream GitHub API failure", async (body, clientIp) => { + const targetRepository = `ContextualWisdomLab/invalid-installation-${clientIp.split(".").at(-1)}`; + const response = await exchangeWith(targetRepository, baseEnv, (url) => { + if (url === `https://api.github.com/repos/${targetRepository}/installation`) { + return new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("unexpected GitHub request", { status: 500 }); + }, clientIp); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API returned invalid JSON shape", + }); + }); + + it("rejects a non-numeric installation id before token minting", async () => { + const targetRepository = "ContextualWisdomLab/invalid-installation-id"; + const response = await exchangeWith(targetRepository, baseEnv, (url) => { + if (url === `https://api.github.com/repos/${targetRepository}/installation`) { + return Response.json({ id: { attacker_controlled: true } }); + } + return new Response("unexpected GitHub request", { status: 500 }); + }, "203.0.113.245"); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API returned invalid installation response", + }); + }); + + it("rejects non-string installation token material instead of coercing it into a credential", async () => { + const response = await exchangeWith( + "ContextualWisdomLab/invalid-token-shape", + { ...baseEnv, GITHUB_APP_INSTALLATION_ID: "92345" }, + (url) => { + if (url === "https://api.github.com/app/installations/92345/access_tokens") { + return Response.json({ + token: { attacker_controlled: true }, + expires_at: "2099-01-01T00:00:00Z", + }); + } + return new Response("unexpected GitHub request", { status: 500 }); + }, + "203.0.113.246", + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API returned invalid installation-token response", + }); + }); + + it("rejects an already-expired installation token instead of returning unusable credential material", async () => { + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2030-01-01T00:00:00Z")); + const response = await exchangeWith( + "ContextualWisdomLab/expired-installation-token", + { ...baseEnv, GITHUB_APP_INSTALLATION_ID: "92345" }, + (url) => { + if (url === "https://api.github.com/app/installations/92345/access_tokens") { + return Response.json({ + token: "ghs_expired", + expires_at: "2029-12-31T23:59:59Z", + }); + } + return new Response("unexpected GitHub request", { status: 500 }); + }, + "203.0.113.247", + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API returned expired installation-token response", + }); + }); + + it("rejects an installation token whose declared lifetime exceeds GitHub's one-hour contract", async () => { + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2030-01-01T00:00:00Z")); + const response = await exchangeWith( + "ContextualWisdomLab/overlong-installation-token", + { ...baseEnv, GITHUB_APP_INSTALLATION_ID: "92345" }, + (url) => { + if (url === "https://api.github.com/app/installations/92345/access_tokens") { + return Response.json({ + token: "ghs_overlong", + expires_at: "2030-01-01T02:00:00Z", + }); + } + return new Response("unexpected GitHub request", { status: 500 }); + }, + "203.0.113.248", + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API returned implausible installation-token expiry", + }); + }); +}); diff --git a/test/github-app-explicit-installation-id-validation.test.ts b/test/github-app-explicit-installation-id-validation.test.ts new file mode 100644 index 000000000..cc0095cb6 --- /dev/null +++ b/test/github-app-explicit-installation-id-validation.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import worker, { type Env } from "../src/index"; + +const configuredRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); + +const baseEnv: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "initialized-in-beforeAll", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +let oidcKeyPair: CryptoKeyPair; +let oidcPublicJwk: JsonWebKey; +let appPrivateKeyPem: string; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(bytes: ArrayBuffer): string { + return Buffer.from(bytes).toString("base64url"); +} + +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} + +async function generateRsaKeyPair(): Promise { + return crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); +} + +beforeAll(async () => { + oidcKeyPair = await generateRsaKeyPair(); + oidcPublicJwk = await crypto.subtle.exportKey("jwk", oidcKeyPair.publicKey); + const appKeyPair = await generateRsaKeyPair(); + appPrivateKeyPem = pemFromPkcs8( + await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function signedOidcToken() { + const kid = `explicit-installation-${crypto.randomUUID()}`; + const now = Math.floor(Date.now() / 1000); + const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); + const payload = encodeSegment({ + iss: baseEnv.ALLOWED_ISSUER, + aud: baseEnv.ALLOWED_AUDIENCE, + repository_owner: baseEnv.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + oidcKeyPair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + return { + token: `${header}.${payload}.${encodeBytes(signature)}`, + jwk: { ...oidcPublicJwk, kid, kty: "RSA" }, + }; +} + +async function exchangeWithConfiguredInstallationId(installationId: string, clientIp: string) { + const { token, jwk } = await signedOidcToken(); + let githubApiCalls = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + }); + } + if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { + return Response.json({ keys: [jwk] }); + } + githubApiCalls += 1; + return new Response("configured installation id must fail before GitHub App egress", { status: 500 }); + }); + + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": clientIp, + }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/noema" }), + }), + { + ...baseEnv, + GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem, + GITHUB_APP_INSTALLATION_ID: installationId, + }, + ); + + return { response, githubApiCalls }; +} + +describe("configured GitHub App installation id", () => { + it.each([ + ["0", "203.0.113.250"], + ["-1", "203.0.113.251"], + ["1.5", "203.0.113.252"], + ["12345/../../repos", "203.0.113.253"], + ["01", "203.0.113.254"], + ["9007199254740992", "203.0.113.255"], + ])("fails closed before GitHub App egress for invalid id %s", async (installationId, clientIp) => { + const { response, githubApiCalls } = await exchangeWithConfiguredInstallationId(installationId, clientIp); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_INSTALLATION", + message: "GitHub App installation id configuration is invalid", + }); + expect(githubApiCalls).toBe(0); + }); +}); diff --git a/test/github-app-id-validation.test.ts b/test/github-app-id-validation.test.ts new file mode 100644 index 000000000..fd29f7513 --- /dev/null +++ b/test/github-app-id-validation.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import entrypoint, { type Env } from "../src/entrypoint"; + +const configuredRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); + +const baseEnv: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "initialized-in-beforeAll", + GITHUB_APP_INSTALLATION_ID: "12345", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +let oidcKeyPair: CryptoKeyPair; +let oidcPublicJwk: JsonWebKey; +let appPrivateKeyPem: string; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(bytes: ArrayBuffer): string { + return Buffer.from(bytes).toString("base64url"); +} + +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} + +async function generateRsaKeyPair(): Promise { + return crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); +} + +beforeAll(async () => { + oidcKeyPair = await generateRsaKeyPair(); + oidcPublicJwk = await crypto.subtle.exportKey("jwk", oidcKeyPair.publicKey); + const appKeyPair = await generateRsaKeyPair(); + appPrivateKeyPem = pemFromPkcs8( + await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function signedOidcToken() { + const kid = `invalid-app-id-${crypto.randomUUID()}`; + const now = Math.floor(Date.now() / 1000); + const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); + const payload = encodeSegment({ + iss: baseEnv.ALLOWED_ISSUER, + aud: baseEnv.ALLOWED_AUDIENCE, + repository_owner: baseEnv.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + oidcKeyPair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + return { + token: `${header}.${payload}.${encodeBytes(signature)}`, + jwk: { ...oidcPublicJwk, kid, kty: "RSA" }, + }; +} + +async function exchangeWithAppId(appId: string, clientIp: string) { + const { token, jwk } = await signedOidcToken(); + let githubApiCalls = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + }); + } + if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { + return Response.json({ keys: [jwk] }); + } + githubApiCalls += 1; + return new Response("invalid App id must fail before GitHub App egress", { status: 500 }); + }); + + const response = await entrypoint.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": clientIp, + }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/noema" }), + }), + { + ...baseEnv, + GITHUB_APP_ID: appId, + GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem, + }, + ); + + return { response, githubApiCalls }; +} + +describe("configured GitHub App id", () => { + it.each([ + ["0", "203.0.113.210"], + ["-1", "203.0.113.211"], + ["1.5", "203.0.113.212"], + ["01", "203.0.113.213"], + ["9007199254740992", "203.0.113.214"], + ])("fails closed at the public edge before GitHub App egress for invalid id %s", async (appId, clientIp) => { + const { response, githubApiCalls } = await exchangeWithAppId(appId, clientIp); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API trust configuration unavailable", + details: { + policy: "github-app-id-canonical", + }, + }); + expect(githubApiCalls).toBe(0); + }); +}); diff --git a/test/github-app-installation-id-edge-validation.test.ts b/test/github-app-installation-id-edge-validation.test.ts new file mode 100644 index 000000000..3e53324ab --- /dev/null +++ b/test/github-app-installation-id-edge-validation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import entrypoint, { type Env } from "../src/entrypoint"; + +const baseEnv: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: "a".repeat(40), + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "not-used-before-request-edge-validation", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +describe("configured GitHub App installation id at the public request edge", () => { + it.each([ + "0", + "-1", + "1.5", + "01", + "9007199254740992", + "12345/../../repos", + ])("fails closed before authentication or replay work for invalid id %s", async (installationId) => { + const response = await entrypoint.fetch( + new Request("https://noema.example/exchange", { method: "POST" }), + { + ...baseEnv, + GITHUB_APP_INSTALLATION_ID: installationId, + }, + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API trust configuration unavailable", + details: { + policy: "github-app-installation-id-canonical", + }, + }); + }); +}); diff --git a/test/github-app-runtime-coverage.test.ts b/test/github-app-runtime-coverage.test.ts index 8774ea1ea..933a47fc1 100644 --- a/test/github-app-runtime-coverage.test.ts +++ b/test/github-app-runtime-coverage.test.ts @@ -124,23 +124,27 @@ async function exchange( ); } -function successfulTokenResponse(token = "ghs_runtime_coverage") { +function successfulTokenResponse( + token = "ghs_runtime_coverage", + expiresAt = new Date(Date.now() + 60 * 60_000).toISOString(), +) { return Response.json({ token, - expires_at: "2030-01-01T00:00:00Z", + expires_at: expiresAt, }); } describe("GitHub App runtime coverage through the public exchange boundary", () => { it("uses an explicit installation id and requests one repository with least privilege", async () => { const calls: Array<{ url: string; init?: RequestInit }> = []; + const expiresAt = new Date(Date.now() + 60 * 60_000).toISOString(); const response = await exchange( "ContextualWisdomLab/noema", { ...baseEnv, GITHUB_APP_INSTALLATION_ID: "12345" }, (url, init) => { calls.push({ url, init }); if (url === "https://api.github.com/app/installations/12345/access_tokens") { - return successfulTokenResponse(); + return successfulTokenResponse("ghs_runtime_coverage", expiresAt); } return new Response("unexpected GitHub request", { status: 500 }); }, @@ -153,7 +157,7 @@ describe("GitHub App runtime coverage through the public exchange boundary", () data: { repository: "ContextualWisdomLab/noema", token: "ghs_runtime_coverage", - token_expires_at: "2030-01-01T00:00:00Z", + token_expires_at: expiresAt, }, }); expect(calls).toHaveLength(1); diff --git a/test/github-installation-expiry-defensive-coverage.test.ts b/test/github-installation-expiry-defensive-coverage.test.ts new file mode 100644 index 000000000..19f34cc67 --- /dev/null +++ b/test/github-installation-expiry-defensive-coverage.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import worker, { type Env } from "../src/index"; + +const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); +let oidcKeyPair: CryptoKeyPair; +let oidcPublicJwk: JsonWebKey; +let appPrivateKeyPem: string; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} +function encodeBytes(bytes: ArrayBuffer): string { + return Buffer.from(bytes).toString("base64url"); +} +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + return `-----BEGIN PRIVATE KEY-----\n${base64.match(/.{1,64}/g)?.join("\n") ?? base64}\n-----END PRIVATE KEY-----`; +} +async function generateRsaKeyPair(): Promise { + return crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, true, ["sign", "verify"]); +} + +const baseEnv: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "initialized-in-beforeAll", + GITHUB_APP_INSTALLATION_ID: "92345", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +beforeAll(async () => { + oidcKeyPair = await generateRsaKeyPair(); + oidcPublicJwk = await crypto.subtle.exportKey("jwk", oidcKeyPair.publicKey); + const appKeyPair = await generateRsaKeyPair(); + appPrivateKeyPem = pemFromPkcs8(await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey)); +}); +afterEach(() => vi.restoreAllMocks()); + +async function exchangeWithTokenResponse(tokenBody: unknown, clientIp: string): Promise { + const kid = `github-expiry-${crypto.randomUUID()}`; + const now = Math.floor(Date.now() / 1000); + const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); + const payload = encodeSegment({ iss: baseEnv.ALLOWED_ISSUER, aud: baseEnv.ALLOWED_AUDIENCE, repository_owner: baseEnv.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: configuredRef, job_workflow_sha: configuredWorkflowSha, exp: now + 300, nbf: now - 30, iat: now - 30 }); + const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", oidcKeyPair.privateKey, new TextEncoder().encode(`${header}.${payload}`)); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) return Response.json({ jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks" }); + if (url.endsWith("/.well-known/jwks")) return Response.json({ keys: [{ ...oidcPublicJwk, kid, kty: "RSA" }] }); + if (url === "https://api.github.com/app/installations/92345/access_tokens") return Response.json(tokenBody); + return new Response("unexpected", { status: 500 }); + }); + return worker.fetch(new Request("https://noema.example/exchange", { method: "POST", headers: { authorization: `Bearer ${header}.${payload}.${encodeBytes(signature)}`, "content-type": "application/json", "cf-connecting-ip": clientIp }, body: JSON.stringify({ target_repository: "ContextualWisdomLab/noema" }) }), { ...baseEnv, GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem }); +} + +describe("GitHub installation expiry defensive coverage", () => { + it("rejects a non-string expires_at instead of coercing timestamp authority", async () => { + const response = await exchangeWithTokenResponse({ token: "ghs_value", expires_at: 123 }, "203.0.113.249"); + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ error_code: "ERR_GITHUB_API", message: "GitHub API returned invalid installation-token response" }); + }); + + it("rejects a parseable but non-canonical offset expiry before granting credential authority", async () => { + const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString().replace(/Z$/, "+00:00"); + const response = await exchangeWithTokenResponse({ token: "ghs_value", expires_at: expiresAt }, "203.0.113.251"); + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ error_code: "ERR_GITHUB_API", message: "GitHub API returned invalid installation-token expiry" }); + }); + + it("fails closed if canonical timestamp serialization itself is unavailable", async () => { + vi.spyOn(Date.prototype, "toISOString").mockImplementation(() => { throw new RangeError("date serialization unavailable"); }); + const response = await exchangeWithTokenResponse({ token: "ghs_value", expires_at: "2030-01-01T00:30:00Z" }, "203.0.113.250"); + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ error_code: "ERR_GITHUB_API", message: "GitHub API returned invalid installation-token expiry" }); + }); +}); diff --git a/test/github-installation-token-expiry-calendar-integrity.test.ts b/test/github-installation-token-expiry-calendar-integrity.test.ts new file mode 100644 index 000000000..c53f67149 --- /dev/null +++ b/test/github-installation-token-expiry-calendar-integrity.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import worker, { type Env } from "../src/index"; + +const configuredRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); + +const baseEnv: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "initialized-in-beforeAll", + GITHUB_APP_INSTALLATION_ID: "92345", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +let oidcKeyPair: CryptoKeyPair; +let oidcPublicJwk: JsonWebKey; +let appPrivateKeyPem: string; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(bytes: ArrayBuffer): string { + return Buffer.from(bytes).toString("base64url"); +} + +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} + +async function generateRsaKeyPair(): Promise { + return crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); +} + +beforeAll(async () => { + oidcKeyPair = await generateRsaKeyPair(); + oidcPublicJwk = await crypto.subtle.exportKey("jwk", oidcKeyPair.publicKey); + const appKeyPair = await generateRsaKeyPair(); + appPrivateKeyPem = pemFromPkcs8( + await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function signedOidcToken(): Promise<{ token: string; jwk: JsonWebKey }> { + const kid = `github-expiry-calendar-${crypto.randomUUID()}`; + const now = Math.floor(Date.now() / 1000); + const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); + const payload = encodeSegment({ + iss: baseEnv.ALLOWED_ISSUER, + aud: baseEnv.ALLOWED_AUDIENCE, + repository_owner: baseEnv.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + oidcKeyPair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + return { + token: `${header}.${payload}.${encodeBytes(signature)}`, + jwk: { ...oidcPublicJwk, kid, kty: "RSA" }, + }; +} + +describe("GitHub installation-token expiry calendar integrity", () => { + it("rejects an impossible calendar expiry that Date.parse would normalize into a live credential", async () => { + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2030-03-02T00:00:00Z")); + const { token, jwk } = await signedOidcToken(); + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + }); + } + if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { + return Response.json({ keys: [jwk] }); + } + if (url === "https://api.github.com/app/installations/92345/access_tokens") { + return Response.json({ + token: "ghs_impossible_calendar_expiry", + expires_at: "2030-02-30T00:30:00Z", + }); + } + return new Response("unexpected GitHub request", { status: 500 }); + }); + + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.249", + }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/expiry-calendar" }), + }), + { ...baseEnv, GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem }, + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API returned invalid installation-token expiry", + }); + }); +}); diff --git a/test/oidc-bearer-whitespace-envelope.test.ts b/test/oidc-bearer-whitespace-envelope.test.ts new file mode 100644 index 000000000..dbef51bab --- /dev/null +++ b/test/oidc-bearer-whitespace-envelope.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import entrypoint, { isBoundedOidcBearer, type Env } from "../src/entrypoint"; + +describe("OIDC bearer envelope whitespace boundary", () => { + it("rejects embedded credential whitespace before downstream JWT parsing", async () => { + const authorization = "Bearer one.two .three"; + expect(isBoundedOidcBearer(authorization)).toBe(false); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const response = await entrypoint.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization, + "x-request-id": "bearer-whitespace-envelope", + }, + }), + { GITHUB_API_BASE: "https://api.github.com" } as Env, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_TOKEN_MALFORMED", + details: { policy: "bounded-oidc-jwt-envelope" }, + trace_id: "bearer-whitespace-envelope", + }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"event":"oidc_token_envelope"')); + expect(logSpy.mock.calls.flat().join("\n")).not.toContain(authorization); + }); +}); diff --git a/test/oidc-numeric-date-finite.test.ts b/test/oidc-numeric-date-finite.test.ts new file mode 100644 index 000000000..c4d9d748f --- /dev/null +++ b/test/oidc-numeric-date-finite.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/index"; + +const configuredWorkflowRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); +const trustedDiscoveryUrl = + "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; +const trustedJwksUrl = "https://token.actions.githubusercontent.com/.well-known/jwks"; +const signingKid = "oidc-numeric-date-finite"; + +const env: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredWorkflowRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +let signingPrivateKey: CryptoKey; +let signingPublicJwk: JsonWebKey; + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(value: Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +async function signedRawPayloadJwt(payloadJson: string): Promise { + const encodedHeader = encodeJson({ alg: "RS256", kid: signingKid }); + const encodedPayload = Buffer.from(payloadJson).toString("base64url"); + const signature = new Uint8Array( + await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + signingPrivateKey, + new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`), + ), + ); + return `${encodedHeader}.${encodedPayload}.${encodeBytes(signature)}`; +} + +function rawClaimsWithNumericDate( + field: "exp" | "nbf" | "iat", + rawNumericDate: string, + now = Math.floor(Date.now() / 1000), +): string { + const claims: Record = { + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: configuredWorkflowRef, + job_workflow_sha: configuredWorkflowSha, + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }; + const finiteValue = claims[field]; + const encoded = JSON.stringify(claims); + return encoded.replace(`"${field}":${finiteValue}`, `"${field}":${rawNumericDate}`); +} + +async function exchange(token: string): Promise { + vi.resetModules(); + const { default: worker } = await import("../src/index"); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === trustedDiscoveryUrl) return Response.json({ jwks_uri: trustedJwksUrl }); + if (url === trustedJwksUrl) { + return Response.json({ keys: [{ ...signingPublicJwk, kid: signingKid, kty: "RSA" }] }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + return worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.122", + }, + body: JSON.stringify({ + target_repository: { owner: "ContextualWisdomLab", repo: "noema" }, + }), + }), + env, + ); +} + +beforeAll(async () => { + const keyPair = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + signingPrivateKey = keyPair.privateKey; + signingPublicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("OIDC NumericDate finiteness", () => { + it("rejects a signed expiration that overflows JSON numeric range instead of treating Infinity as unexpired", async () => { + const token = await signedRawPayloadJwt(rawClaimsWithNumericDate("exp", "1e400")); + const response = await exchange(token); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + }); + }); + + it("rejects a signed negative not-before overflow instead of treating negative Infinity as already valid", async () => { + const token = await signedRawPayloadJwt(rawClaimsWithNumericDate("nbf", "-1e400")); + const response = await exchange(token); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + }); + }); + + it("rejects a signed issued-at value that overflows JSON numeric range", async () => { + const token = await signedRawPayloadJwt(rawClaimsWithNumericDate("iat", "1e400")); + const response = await exchange(token); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + }); + }); + + it("rejects a signed issued-at value that is materially in the future", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await signedRawPayloadJwt(rawClaimsWithNumericDate("iat", String(now + 300), now)); + const response = await exchange(token); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + }); + }); +}); diff --git a/test/oidc-repository-owner-id-binding.test.ts b/test/oidc-repository-owner-id-binding.test.ts new file mode 100644 index 000000000..2d1d3c151 --- /dev/null +++ b/test/oidc-repository-owner-id-binding.test.ts @@ -0,0 +1,167 @@ +import { beforeAll, afterEach, describe, expect, it, vi } from "vitest"; +import worker, { type Env } from "../src/index"; + +const configuredRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); +const expectedRepositoryOwnerId = "295022177"; +const expectedNoemaRepositoryId = "1285107801"; +const expectedWorkflowRepositoryId = "1274066402"; + +let oidcKeyPair: CryptoKeyPair; +let oidcPublicJwk: JsonWebKey; +let appPrivateKeyPem: string; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(bytes: ArrayBuffer): string { + return Buffer.from(bytes).toString("base64url"); +} + +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} + +async function generateRsaKeyPair(): Promise { + return crypto.subtle.generateKey( + { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"], + ); +} + +beforeAll(async () => { + oidcKeyPair = await generateRsaKeyPair(); + oidcPublicJwk = await crypto.subtle.exportKey("jwk", oidcKeyPair.publicKey); + const appKeyPair = await generateRsaKeyPair(); + appPrivateKeyPem = pemFromPkcs8(await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey)); +}); + +afterEach(() => vi.restoreAllMocks()); + +async function signedOidcToken( + repositoryOwnerId: string, + repository = "ContextualWisdomLab/noema", + repositoryId = expectedNoemaRepositoryId, +) { + const kid = `github-owner-id-${crypto.randomUUID()}`; + const now = Math.floor(Date.now() / 1000); + const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); + const payload = encodeSegment({ + iss: "https://token.actions.githubusercontent.com", + aud: "cwl-noema-review", + repository_owner: "ContextualWisdomLab", + repository_owner_id: repositoryOwnerId, + repository, + repository_id: repositoryId, + job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + oidcKeyPair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + return { token: `${header}.${payload}.${encodeBytes(signature)}`, jwk: { ...oidcPublicJwk, kid, kty: "RSA" } }; +} + +function runtimeEnv(): Env { + return { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem, + GITHUB_APP_INSTALLATION_ID: "92345", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + }; +} + +async function exerciseToken(token: string, jwk: JsonWebKey, clientIp: string) { + let githubAppEgressCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks" }); + } + if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") return Response.json({ keys: [jwk] }); + githubAppEgressCount += 1; + return new Response("expected downstream boundary", { status: 500 }); + }); + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "cf-connecting-ip": clientIp }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/noema" }), + }), + runtimeEnv(), + ); + return { response, githubAppEgressCount }; +} + +async function expectRepositoryIdentityRejection( + repository: string, + repositoryId: string, + clientIp: string, +) { + const { token, jwk } = await signedOidcToken(expectedRepositoryOwnerId, repository, repositoryId); + const { response, githubAppEgressCount } = await exerciseToken(token, jwk, clientIp); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_REPO_NOT_ALLOWED", + message: "OIDC repository identity is not allowed", + }); + expect(githubAppEgressCount).toBe(0); +} + +describe("OIDC immutable repository identity", () => { + it("rejects a signed same-name owner carrying a different GitHub owner id before GitHub App egress", async () => { + const { token, jwk } = await signedOidcToken("1"); + const { response, githubAppEgressCount } = await exerciseToken(token, jwk, "203.0.113.250"); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_REPO_NOT_ALLOWED", + message: "OIDC repository owner identity is not allowed", + }); + expect(githubAppEgressCount).toBe(0); + }); + + it("rejects a same-name Noema repository carrying a different immutable repository id", async () => { + await expectRepositoryIdentityRejection("ContextualWisdomLab/noema", "1", "203.0.113.252"); + }); + + it("rejects a same-name central workflow repository carrying a different immutable repository id", async () => { + await expectRepositoryIdentityRejection("ContextualWisdomLab/.github", "1", "203.0.113.254"); + }); + + it("allows the current Noema organization and repository ids through the immutable-identity boundary", async () => { + const { token, jwk } = await signedOidcToken(expectedRepositoryOwnerId); + const { response, githubAppEgressCount } = await exerciseToken(token, jwk, "203.0.113.251"); + expect(response.status).not.toBe(403); + expect(githubAppEgressCount).toBe(1); + }); + + it("allows the current central workflow repository id instead of comparing it to Noema's repository id", async () => { + const { token, jwk } = await signedOidcToken( + expectedRepositoryOwnerId, + "ContextualWisdomLab/.github", + expectedWorkflowRepositoryId, + ); + const { response, githubAppEgressCount } = await exerciseToken(token, jwk, "203.0.113.253"); + expect(response.status).not.toBe(403); + expect(githubAppEgressCount).toBe(1); + }); +}); diff --git a/test/oidc-verification-residual-coverage.test.ts b/test/oidc-verification-residual-coverage.test.ts index 9587aea6a..95b7540dc 100644 --- a/test/oidc-verification-residual-coverage.test.ts +++ b/test/oidc-verification-residual-coverage.test.ts @@ -254,6 +254,19 @@ describe("OIDC verification residual coverage", () => { }); }); + it("rejects a non-numeric not-before claim instead of silently ignoring it", async () => { + const claims = baseClaims(); + claims.nbf = "not-a-numeric-date"; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + message: "OIDC not-before claim is invalid", + }); + }); + it("rejects a token without a numeric expiration", async () => { const claims = baseClaims(); delete claims.exp; diff --git a/test/outbound-fetch-cleanup-liveness.test.ts b/test/outbound-fetch-cleanup-liveness.test.ts new file mode 100644 index 000000000..1462bf8d3 --- /dev/null +++ b/test/outbound-fetch-cleanup-liveness.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFailClosedFetch, type FetchLike } from "../src/outbound-fetch-policy"; + +const trustedUrl = "https://api.github.com/meta"; + +async function boundedOutcome(response: Response): Promise { + const rawFetch = vi.fn(async () => response); + const wrapped = createFailClosedFetch(rawFetch); + return Promise.race([ + wrapped(trustedUrl).then((value) => value.headers.get("x-noema-egress-policy") ?? "allowed"), + new Promise((resolve) => { + setTimeout(() => resolve("cleanup-timeout"), 500); + }), + ]); +} + +describe("outbound response cleanup liveness", () => { + it("does not await a never-settling cancellation after declared oversize rejection", async () => { + const cancel = vi.fn(() => new Promise(() => {})); + const response = new Response(new ReadableStream({ cancel }), { + headers: { "content-length": "1048577" }, + }); + + expect(await boundedOutcome(response)).toBe("blocked-response-size"); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("does not await a never-settling reader cancellation after streamed overflow", async () => { + const cancel = vi.fn(() => new Promise(() => {})); + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(1_048_577)); + }, + cancel, + })); + + expect(await boundedOutcome(response)).toBe("blocked-response-size"); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("cleans up a response stream that fails while being read without replacing the fail-closed result", async () => { + const response = new Response("ignored"); + const cancel = vi.fn(async () => undefined); + vi.spyOn(response.body!, "getReader").mockReturnValue({ + read: vi.fn(async () => { + throw new Error("synthetic outbound response read failure"); + }), + cancel, + } as unknown as ReadableStreamDefaultReader); + + expect(await boundedOutcome(response)).toBe("blocked-response-read"); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("does not retain or await a blocked redirect response body", async () => { + const cancel = vi.fn(() => new Promise(() => {})); + const response = new Response(new ReadableStream({ cancel }), { + status: 302, + headers: { location: "https://example.invalid/redirect-target" }, + }); + + expect(await boundedOutcome(response)).toBe("blocked-redirect"); + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/outbound-fetch-installation-id-range.test.ts b/test/outbound-fetch-installation-id-range.test.ts new file mode 100644 index 000000000..6f81777a6 --- /dev/null +++ b/test/outbound-fetch-installation-id-range.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { isTrustedCredentialEgressRequest } from "../src/outbound-fetch-policy"; + +const body = JSON.stringify({ + repositories: ["noema"], + permissions: { + contents: "read", + pull_requests: "write", + checks: "read", + }, +}); + +function requestFor(id: string) { + return isTrustedCredentialEgressRequest( + `https://api.github.com/app/installations/${id}/access_tokens`, + { + method: "POST", + headers: { + authorization: "Bearer app-jwt", + "content-type": "application/json", + }, + body, + }, + ); +} + +describe("credential egress installation-id authority", () => { + it("accepts the maximum canonical safe integer installation id", () => { + expect(requestFor(String(Number.MAX_SAFE_INTEGER))).toBe(true); + }); + + it.each([ + "9007199254740992", + "9999999999999999999999999999999999999999", + ])("rejects an installation id outside the JavaScript safe-integer boundary: %s", (id) => { + expect(requestFor(id)).toBe(false); + }); +}); diff --git a/test/outbound-fetch-policy.test.ts b/test/outbound-fetch-policy.test.ts index 17e18ae5e..f50f5f641 100644 --- a/test/outbound-fetch-policy.test.ts +++ b/test/outbound-fetch-policy.test.ts @@ -169,6 +169,22 @@ describe("credential-bearing outbound fetch policy", () => { expect(cancel).toHaveBeenCalledOnce(); }); + it("converts a response stream failure into a bodyless fail-closed gateway response", async () => { + const body = new ReadableStream({ + pull(controller) { + controller.error(new Error("upstream body failed")); + }, + }); + const rawFetch = vi.fn(async () => new Response(body)); + const wrapped = createFailClosedFetch(rawFetch); + + const response = await wrapped("https://api.github.com/meta"); + + expect(response.status).toBe(502); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-response-read"); + expect(await response.text()).toBe(""); + }); + it("accepts a chunked response exactly at the one-megabyte boundary", async () => { const body = new ReadableStream({ start(controller) { diff --git a/test/outbound-fetch-transport-failure.test.ts b/test/outbound-fetch-transport-failure.test.ts new file mode 100644 index 000000000..7c24011a9 --- /dev/null +++ b/test/outbound-fetch-transport-failure.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { createFailClosedFetch } from "../src/outbound-fetch-policy"; + +describe("outbound credential transport failures", () => { + it("classifies a trusted GitHub API transport rejection as a bounded 502 response", async () => { + const protectedFetch = createFailClosedFetch(async () => { + throw new TypeError("synthetic network transport failure"); + }); + + const response = await protectedFetch( + "https://api.github.com/repos/ContextualWisdomLab/noema/installation", + { + method: "GET", + headers: { authorization: "Bearer synthetic-app-jwt" }, + }, + ); + + expect(response.status).toBe(502); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("x-noema-egress-policy")).toBe("blocked-transport"); + await expect(response.text()).resolves.toBe(""); + }); +}); diff --git a/test/package-manager-reproducibility.test.ts b/test/package-manager-reproducibility.test.ts index 24d291a67..fd29ce6f1 100644 --- a/test/package-manager-reproducibility.test.ts +++ b/test/package-manager-reproducibility.test.ts @@ -192,13 +192,15 @@ describe("package-manager reproducibility contract", () => { it("binds lockfile validation to one fresh live base and refuses base movement during verification", () => { const beforeGate = ciWorkflow.indexOf("name: verify live pull-request base before lockfile control"); const lockfileGate = ciWorkflow.indexOf("name: verify lockfile change control"); - const releaseVerify = ciWorkflow.indexOf("name: release verify"); + const releaseStart = ciWorkflow.indexOf("name: release typecheck"); + const releaseEnd = ciWorkflow.indexOf("name: release acquisition integrity"); const afterGate = ciWorkflow.indexOf("name: refuse pull-request base drift after verification"); expect(beforeGate).toBeGreaterThan(-1); expect(lockfileGate).toBeGreaterThan(beforeGate); - expect(releaseVerify).toBeGreaterThan(lockfileGate); - expect(afterGate).toBeGreaterThan(releaseVerify); + expect(releaseStart).toBeGreaterThan(lockfileGate); + expect(releaseEnd).toBeGreaterThan(releaseStart); + expect(afterGate).toBeGreaterThan(releaseEnd); expect(ciWorkflow).toContain("NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}"); expect(ciWorkflow).toContain( 'git merge-base --is-ancestor "$live_base_sha" "$NOEMA_EXPECTED_HEAD_SHA"', diff --git a/test/replay-request-core-coverage.test.ts b/test/replay-request-core-coverage.test.ts index 77b64dd7f..f60bd2c15 100644 --- a/test/replay-request-core-coverage.test.ts +++ b/test/replay-request-core-coverage.test.ts @@ -113,7 +113,7 @@ function installOidcFetch(jwk: JsonWebKey, env: Env, installationToken = false) if (installationToken && url === `${env.GITHUB_API_BASE}/app/installations/12345/access_tokens`) { return Response.json({ token: "ghs_replay_coverage_token", - expires_at: "2030-01-01T00:00:00Z", + expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), }); } return new Response("not found", { status: 404 }); diff --git a/test/runtime-readiness-id-range.test.ts b/test/runtime-readiness-id-range.test.ts new file mode 100644 index 000000000..4e9b3e27d --- /dev/null +++ b/test/runtime-readiness-id-range.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateRuntimeReadiness, + type RuntimeReadinessEnv, +} from "../src/runtime-readiness"; + +function dummyNamespace(): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return {} as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +function baseEnv(): RuntimeReadinessEnv { + return { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "123456", + // Deliberately non-importable. These tests assert the identifier-specific + // failure remains present in addition to the independent key failure. + GITHUB_APP_PRIVATE_KEY_PEM: "not-a-private-key", + GITHUB_APP_INSTALLATION_ID: "987654", + NOEMA_RATE_LIMITER: dummyNamespace(), + NOEMA_OIDC_REPLAY_GUARD: dummyNamespace(), + }; +} + +describe("runtime readiness GitHub numeric identifier bounds", () => { + it.each([ + ["GITHUB_APP_ID", "github_app_id"], + ["GITHUB_APP_INSTALLATION_ID", "github_app_installation_id"], + ] as const)( + "rejects a %s value outside JavaScript's exact safe-integer range", + async (field, expectedFailure) => { + const env = baseEnv(); + env[field] = "9007199254740992"; + + const result = await evaluateRuntimeReadiness(env); + + expect(result.ready).toBe(false); + expect(result.failedChecks).toContain(expectedFailure); + expect(result.failedChecks).toContain("github_app_private_key"); + }, + ); +}); diff --git a/test/runtime-readiness-ref-format.test.ts b/test/runtime-readiness-ref-format.test.ts index 9e2d3116a..b6a956899 100644 --- a/test/runtime-readiness-ref-format.test.ts +++ b/test/runtime-readiness-ref-format.test.ts @@ -79,10 +79,14 @@ describe("runtime-readiness exact Git ref validation", () => { const env = await readyEnvironment(); env.ALLOWED_WORKFLOW_REF_PREFIX = `ContextualWisdomLab/.github/.github/workflows/noema-review.yml@${refName}`; + if (/^[0-9a-f]{40}$/.test(refName)) { + env.ALLOWED_WORKFLOW_SHA = refName; + } const result = await evaluateRuntimeReadiness(env); expect(result.ready).toBe(true); expect(result.failedChecks).not.toContain("allowed_workflow_ref"); + expect(result.failedChecks).not.toContain("allowed_workflow_sha"); }); }); diff --git a/test/runtime-readiness-workflow-source-coherence.test.ts b/test/runtime-readiness-workflow-source-coherence.test.ts new file mode 100644 index 000000000..6d58a09bd --- /dev/null +++ b/test/runtime-readiness-workflow-source-coherence.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateRuntimeReadiness, + type RuntimeReadinessEnv, +} from "../src/runtime-readiness"; + +function dummyNamespace(): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return {} as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +function baseEnv(): RuntimeReadinessEnv { + return { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@0123456789abcdef0123456789abcdef01234567", + ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "123456", + GITHUB_APP_PRIVATE_KEY_PEM: "not-a-private-key", + GITHUB_APP_INSTALLATION_ID: "987654", + NOEMA_RATE_LIMITER: dummyNamespace(), + NOEMA_OIDC_REPLAY_GUARD: dummyNamespace(), + }; +} + +describe("runtime readiness immutable workflow-source coherence", () => { + it("fails closed when an immutable workflow ref commit disagrees with ALLOWED_WORKFLOW_SHA", async () => { + const env = baseEnv(); + env.ALLOWED_WORKFLOW_SHA = "89abcdef0123456789abcdef0123456789abcdef"; + + const result = await evaluateRuntimeReadiness(env); + + expect(result.ready).toBe(false); + expect(result.failedChecks).toContain("allowed_workflow_sha"); + expect(result.failedChecks).not.toContain("allowed_workflow_ref"); + }); + + it("accepts a coherent immutable workflow ref and source SHA at the workflow-source boundary", async () => { + const result = await evaluateRuntimeReadiness(baseEnv()); + + expect(result.failedChecks).not.toContain("allowed_workflow_ref"); + expect(result.failedChecks).not.toContain("allowed_workflow_sha"); + expect(result.failedChecks).toContain("github_app_private_key"); + }); +}); diff --git a/test/runtime-workflow-prefilter-coverage.test.ts b/test/runtime-workflow-prefilter-coverage.test.ts index 68704ee2a..462d2fdcd 100644 --- a/test/runtime-workflow-prefilter-coverage.test.ts +++ b/test/runtime-workflow-prefilter-coverage.test.ts @@ -63,7 +63,23 @@ describe("runtime workflow-source prefilter coverage", () => { await expectMissingAuth(); }); - it("does not treat whitespace-only bearer credentials as a source-policy JWT", async () => { - await expectMissingAuth({ authorization: "Bearer " }); + it("rejects whitespace-only Bearer credentials as a malformed bounded JWT envelope", async () => { + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + "cf-connecting-ip": "203.0.113.126", + authorization: "Bearer ", + }, + }), + env, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_TOKEN_MALFORMED", + details: { policy: "bounded-oidc-jwt-envelope" }, + }); }); }); diff --git a/test/trusted-workflow-source-rollforward.test.ts b/test/trusted-workflow-source-rollforward.test.ts index 637eb62e4..22ae50354 100644 --- a/test/trusted-workflow-source-rollforward.test.ts +++ b/test/trusted-workflow-source-rollforward.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const auditedCentralWorkflowSourceSha = - "fce028b4c3bf8e2e5e4819c1c5622e90cfa6ab39"; + "5a8b83773bd5190d972eec3d7c76ac9504665f21"; describe("trusted central workflow source revision", () => { it("binds the deployed OIDC trust configuration to the audited central source commit", () => { diff --git a/test/worker.test.ts b/test/worker.test.ts index b605d8f98..390a1e815 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -374,6 +374,7 @@ describe("Noema worker", () => { ); const appPrivateKey = pemFromPkcs8(await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey)); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const tokenExpiresAt = new Date(Date.now() + 60 * 60_000).toISOString(); const requests: Array<{ url: string; method: string; body?: string }> = []; vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { const url = String(input); @@ -390,7 +391,7 @@ describe("Noema worker", () => { if (url === "https://api.github.com/app/installations/12345/access_tokens") { return Response.json({ token: "ghs_installation_token", - expires_at: "2026-07-02T05:00:00Z", + expires_at: tokenExpiresAt, }); } return new Response("not found", { status: 404 }); @@ -423,7 +424,7 @@ describe("Noema worker", () => { token: "ghs_installation_token", repository: "ContextualWisdomLab/noema", workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", - token_expires_at: "2026-07-02T05:00:00Z", + token_expires_at: tokenExpiresAt, }, }); const tokenRequest = requests.find((request) => request.url.endsWith("/app/installations/12345/access_tokens")); diff --git a/wrangler.toml b/wrangler.toml index c7a9e3490..b4d9b457a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -24,7 +24,7 @@ ALLOWED_AUDIENCE = "cwl-noema-review" ALLOWED_REPOSITORY_OWNER = "ContextualWisdomLab" ALLOWED_WORKFLOW_REPOSITORY = "ContextualWisdomLab/.github" ALLOWED_WORKFLOW_REF_PREFIX = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main" -ALLOWED_WORKFLOW_SHA = "fce028b4c3bf8e2e5e4819c1c5622e90cfa6ab39" +ALLOWED_WORKFLOW_SHA = "5a8b83773bd5190d972eec3d7c76ac9504665f21" GITHUB_API_BASE = "https://api.github.com" GITHUB_APP_SLUG = "noema" NOEMA_RATE_LIMIT_PER_MINUTE = "60"