diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a599bd9..21f1d1a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased - credential-bearing GitHub App REST 요청의 egress를 exact `https://api.github.com` origin으로 고정. 새 Worker entrypoint가 `/exchange` 전에 `GITHUB_API_BASE`의 scheme·origin·userinfo·port·path·query·fragment를 검증하고, lookalike/malformed 설정은 rate-limit·OIDC parsing·private-key 사용 전에 `503 ERR_GITHUB_API`로 실패-폐쇄하며 허용 값도 canonical origin으로 치환한다. `/health`는 설정 복구 중에도 유지하고 원본 설정값은 응답·로그에 노출하지 않는다. +- `src/**/*.ts` 전체에 statements·branches·functions·lines 100% coverage threshold를 강제하고, `/exchange` wrapper·OIDC replay guard·distributed limiter의 fail-closed 및 malformed-decision 경계를 회귀 테스트로 고정했다. 새 source branch가 coverage를 낮추면 CI가 즉시 실패한다. - `/exchange` distributed rate-limit identity가 없는 요청을 shared `unknown` bucket으로 합치지 않고 `503`으로 실패-폐쇄하도록 강화. Cloudflare의 `CF-Connecting-IP`가 정확히 하나의 유효한 IPv4/IPv6가 아니면 Durable Object lookup과 bearer parsing 전에 중단하고, 유효한 IPv6는 canonical form으로 정규화하여 동일 주소의 표기 차이가 rate-limit bucket을 분할하지 않도록 한다. - CI 검증 중 공개된 `undici` 취약점 묶음(GHSA-4cwx-7wf7-3272 포함)을 제거하기 위해 Wrangler→Miniflare 경유 transitive dependency를 patched `7.29.0`으로 override하고 lockfile을 재생성했다. `npm audit --audit-level=high`를 0건으로 복구하고 release gate가 취약 버전에서 실패-폐쇄하도록 유지한다. - EOL 상태인 Node.js 20을 배포 계약에서 제거하고 `engines.node >=22` 및 배포 가이드의 지원 중 LTS 요구사항을 일치시켰다. diff --git a/test/distributed-rate-limit.test.ts b/test/distributed-rate-limit.test.ts index b5b683eb9..42dd422f2 100644 --- a/test/distributed-rate-limit.test.ts +++ b/test/distributed-rate-limit.test.ts @@ -444,3 +444,92 @@ describe("distributed exchange rate limit", () => { }))).status).toBe(400); }); }); + +describe("distributed rate limit fail-closed edges", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("fails closed when the limiter returns a non-object decision", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { "cf-connecting-ip": "203.0.113.20" }, + }), + envWith(async () => Response.json(null)), + ); + + expect(response.status).toBe(503); + }); + + it("fails closed when the limiter Durable Object returns a non-2xx status", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { "cf-connecting-ip": "203.0.113.21" }, + }), + envWith(async () => Response.json({ error: "boom" }, { status: 500 })), + ); + + expect(response.status).toBe(503); + }); + + it("wraps a non-Error thrown by the limiter Durable Object", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { "cf-connecting-ip": "203.0.113.22" }, + }), + envWith(async () => { + throw "opaque limiter failure"; + }), + ); + + expect(response.status).toBe(503); + }); + + it("rejects a non-object limiter payload", async () => { + const limiter = new NoemaRateLimiter(fakeDurableObjectState().state); + const response = await limiter.fetch(new Request("https://noema-rate-limit.internal/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(123), + })); + + expect(response.status).toBe(400); + }); + + it("rejects a non-integer limiter value", async () => { + const limiter = new NoemaRateLimiter(fakeDurableObjectState().state); + const response = await limiter.fetch(new Request("https://noema-rate-limit.internal/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ limit: 2.5 }), + })); + + expect(response.status).toBe(400); + }); + + it("rejects malformed limiter JSON", async () => { + const limiter = new NoemaRateLimiter(fakeDurableObjectState().state); + const response = await limiter.fetch(new Request("https://noema-rate-limit.internal/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not-json", + })); + + expect(response.status).toBe(400); + }); + + it("rejects a limiter request without a JSON content type", async () => { + const limiter = new NoemaRateLimiter(fakeDurableObjectState().state); + const response = await limiter.fetch(new Request("https://noema-rate-limit.internal/check", { + method: "POST", + })); + + expect(response.status).toBe(415); + }); +}); diff --git a/test/oidc-replay.test.ts b/test/oidc-replay.test.ts index 3934560f6..21db221ac 100644 --- a/test/oidc-replay.test.ts +++ b/test/oidc-replay.test.ts @@ -247,3 +247,113 @@ describe("OIDC replay protection", () => { expect(wranglerSource).toContain('storage = "sqlite"'); }); }); + +describe("OIDC replay guard fail-closed edges", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects a decision body that is not an object", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + await expect(claimOidcTokenUsage( + "safe-jti", + 2_600, + { NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(async () => Response.json(5)) }, + )).rejects.toBeInstanceOf(OidcReplayUnavailable); + }); + + it("rejects a non-JSON decision body from the guard", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + await expect(claimOidcTokenUsage( + "safe-jti", + 2_600, + { + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(async () => new Response("not json", { + status: 200, + headers: { "content-type": "application/json" }, + })), + }, + )).rejects.toMatchObject({ + name: "OidcReplayUnavailable", + message: "OIDC replay guard returned non-JSON data", + }); + }); + + it("rejects a decision whose expiry does not match the claimed token", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + await expect(claimOidcTokenUsage( + "safe-jti", + 2_600, + { + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(async () => Response.json({ + accepted: true, + expires_at_epoch_seconds: 2_601, + })), + }, + )).rejects.toBeInstanceOf(OidcReplayUnavailable); + }); + + it("treats a non-accepted, non-conflict decision as guard unavailability", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + await expect(claimOidcTokenUsage( + "safe-jti", + 2_600, + { + // A well-formed decision that is neither accepted nor a 409 conflict. + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(async () => Response.json({ + accepted: false, + expires_at_epoch_seconds: 2_600, + })), + }, + )).rejects.toBeInstanceOf(OidcReplayUnavailable); + }); + + it("wraps an Error thrown by the Durable Object stub", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + await expect(claimOidcTokenUsage( + "safe-jti", + 2_600, + { + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(async () => { + throw new Error("stub transport failure"); + }), + }, + )).rejects.toMatchObject({ + name: "OidcReplayUnavailable", + message: "stub transport failure", + }); + }); + + it("wraps a non-Error thrown by the Durable Object stub", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + await expect(claimOidcTokenUsage( + "safe-jti", + 2_600, + { + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(async () => { + throw "opaque stub failure"; + }), + }, + )).rejects.toMatchObject({ + name: "OidcReplayUnavailable", + message: "unknown Durable Object failure", + }); + }); + + it("rejects a well-formed but non-object claim body and a missing JSON content type", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000_000); + const guard = new NoemaOidcReplayGuard(fakeDurableObjectState().state); + + const nonObject = await guard.fetch(new Request("https://noema-oidc-replay.internal/claim", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify([2_600]), + })); + const noBody = await guard.fetch(new Request("https://noema-oidc-replay.internal/claim", { + method: "POST", + })); + + expect(nonObject.status).toBe(400); + expect(noBody.status).toBe(415); + }); +}); diff --git a/test/worker-defensive-rate-limit.test.ts b/test/worker-defensive-rate-limit.test.ts new file mode 100644 index 000000000..244721fc7 --- /dev/null +++ b/test/worker-defensive-rate-limit.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// checkDistributedRateLimit wraps every internal failure in +// DistributedRateLimitUnavailable, so the wrapper's fail-closed branch that +// handles an *unexpected* (non-wrapped) error type can only be exercised by +// injecting such an error at the module boundary. This verifies the wrapper +// still fails closed with a generic detail rather than propagating the raw +// error or failing open. The real rate-limit behavior is covered in +// distributed-rate-limit.test.ts. +vi.mock("../src/rate-limit", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + checkDistributedRateLimit: vi.fn(async () => { + throw new Error("raw non-wrapped limiter failure"); + }), + }; +}); + +import worker, { type Env } from "../src/worker"; + +function dummyNamespace(): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return { + fetch: async () => new Response("unused", { status: 500 }), + } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +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: + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused", + NOEMA_RATE_LIMIT_PER_MINUTE: "60", + NOEMA_RATE_LIMITER: dummyNamespace(), +}; + +describe("wrapper defensive rate-limit fallback", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("fails closed with a generic detail when the limiter throws an unexpected error", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { "cf-connecting-ip": "203.0.113.70" }, + }), + env, + ); + + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("1"); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_RATE_LIMIT", + message: "Distributed rate limiter unavailable", + details: { scope: "distributed" }, + }); + }); +}); diff --git a/test/worker-defensive-replay.test.ts b/test/worker-defensive-replay.test.ts new file mode 100644 index 000000000..f500a5bef --- /dev/null +++ b/test/worker-defensive-replay.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// claimOidcTokenUsage only ever throws OidcReplayDetected or +// OidcReplayUnavailable (it wraps every other failure), so the wrapper's +// fail-closed branch that handles an *unexpected* (non-wrapped) error type from +// the replay guard can only be exercised by injecting such an error at the +// module boundary. The base worker is mocked to a successful exchange so the +// wrapper reaches its post-exchange replay-consumption step; real replay-guard +// behavior is covered in oidc-replay.test.ts and worker-exchange-replay.test.ts. +vi.mock("../src/index", () => ({ + default: { + fetch: vi.fn(async () => + new Response( + JSON.stringify({ ok: true, data: { token: "ghs_installation_token" }, trace_id: "base" }), + { status: 200, headers: { "content-type": "application/json; charset=utf-8" } }, + )), + }, +})); + +vi.mock("../src/oidc-replay", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + claimOidcTokenUsage: vi.fn(async () => { + throw new Error("raw non-wrapped replay-guard failure"); + }), + }; +}); + +import worker, { type Env } from "../src/worker"; + +const configuredRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function namespaceReturning( + handler: (input: RequestInfo | URL, init?: RequestInit) => Promise, +): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return { fetch: handler } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +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: configuredRef, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_RATE_LIMITER: namespaceReturning(async () => + Response.json({ allowed: true, limit: 1000, remaining: 999, retry_after_seconds: 0 })), + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(async () => + Response.json({ accepted: true, expires_at_epoch_seconds: 1 }, { status: 201 })), +}; + +describe("wrapper defensive replay-guard fallback", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("fails closed with a generic detail when replay consumption throws an unexpected error", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const token = `${encodeSegment({ alg: "RS256", kid: "test" })}.${encodeSegment({ + job_workflow_ref: configuredRef, + jti: "safe-jti", + exp: Math.floor(Date.now() / 1000) + 300, + })}.signature`; + + 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.71", + }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/noema" }), + }), + env, + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_REPLAY", + message: "OIDC replay protection unavailable", + }); + }); +}); diff --git a/test/worker-exchange-replay.test.ts b/test/worker-exchange-replay.test.ts new file mode 100644 index 000000000..3d046e3fa --- /dev/null +++ b/test/worker-exchange-replay.test.ts @@ -0,0 +1,329 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The wrapper worker (src/worker.ts) layers distributed rate limiting, exact +// workflow-ref trust, and single-use OIDC replay protection around the base +// token-exchange worker (src/index.ts). The base worker's own behavior is +// covered end-to-end in worker.test.ts; here we isolate the wrapper by mocking +// the base worker as a boundary so we can drive its post-exchange replay logic +// deterministically without re-signing a full GitHub App exchange. +vi.mock("../src/index", () => ({ + default: { + fetch: vi.fn(async () => + new Response( + JSON.stringify({ + ok: true, + data: { + token: "ghs_installation_token", + repository: "ContextualWisdomLab/noema", + }, + trace_id: "base-trace", + }), + { status: 200, headers: { "content-type": "application/json; charset=utf-8" } }, + )), + }, +})); + +import worker, { type Env } from "../src/worker"; + +const baseEnv = { + 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", + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +const configuredRef = baseEnv.ALLOWED_WORKFLOW_REF_PREFIX; + +type MockFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function craftToken( + payload: Record, + header: Record = { alg: "RS256", kid: "test" }, +): string { + return `${encodeSegment(header)}.${encodeSegment(payload)}.signature`; +} + +function namespaceReturning(handler: MockFetch): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return { fetch: handler } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +function allowRateLimiter(): DurableObjectNamespace { + return namespaceReturning(async () => + Response.json({ allowed: true, limit: 1000, remaining: 999, retry_after_seconds: 0 })); +} + +const acceptGuard: MockFetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")); + return Response.json( + { accepted: true, expires_at_epoch_seconds: body.expires_at_epoch_seconds }, + { status: 201 }, + ); +}; + +const conflictGuard: MockFetch = async (_input, init) => { + const body = JSON.parse(String(init?.body ?? "{}")); + return Response.json( + { accepted: false, expires_at_epoch_seconds: body.expires_at_epoch_seconds }, + { status: 409 }, + ); +}; + +function exchangeRequest(headers: Record): Request { + return new Request("https://noema.example/exchange", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/noema" }), + }); +} + +function validReplayClaims(): Record { + return { + job_workflow_ref: configuredRef, + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + jti: `jti-${crypto.randomUUID()}`, + exp: Math.floor(Date.now() / 1000) + 300, + }; +} + +describe("exchange wrapper replay protection", () => { + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("consumes the OIDC token exactly once on a successful exchange", async () => { + const env: Env = { + ...baseEnv, + NOEMA_RATE_LIMITER: allowRateLimiter(), + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(acceptGuard), + }; + const response = await worker.fetch( + exchangeRequest({ + authorization: `Bearer ${craftToken(validReplayClaims())}`, + "cf-connecting-ip": "203.0.113.60", + }), + env, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("x-oidc-replay-protection")).toBe("single-use"); + expect(response.headers.get("x-rate-limit-scope")).toBe("distributed"); + expect(response.headers.get("x-rate-limit-limit")).toBe("1000"); + expect(response.headers.get("x-rate-limit-remaining")).toBe("999"); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + data: { token: "ghs_installation_token" }, + }); + }); + + it("rejects a replayed OIDC token with a 401 challenge", async () => { + const env: Env = { + ...baseEnv, + NOEMA_RATE_LIMITER: allowRateLimiter(), + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(conflictGuard), + }; + const response = await worker.fetch( + exchangeRequest({ + authorization: `Bearer ${craftToken(validReplayClaims())}`, + "cf-connecting-ip": "203.0.113.61", + }), + env, + ); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer realm="noema", error="invalid_token"', + ); + expect(response.headers.get("x-rate-limit-scope")).toBe("distributed"); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_REPLAY", + message: "OIDC token has already been exchanged", + }); + }); + + it("fails closed with a 503 when the replay guard binding is unavailable", async () => { + const env: Env = { + ...baseEnv, + NOEMA_RATE_LIMITER: allowRateLimiter(), + // NOEMA_OIDC_REPLAY_GUARD intentionally omitted so claimOidcTokenUsage + // throws OidcReplayUnavailable and the wrapper must fail closed. + }; + const response = await worker.fetch( + exchangeRequest({ + authorization: `Bearer ${craftToken(validReplayClaims())}`, + "cf-connecting-ip": "203.0.113.62", + }), + env, + ); + + expect(response.status).toBe(503); + expect(response.headers.get("www-authenticate")).toBeNull(); + expect(response.headers.get("x-rate-limit-scope")).toBe("distributed"); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_REPLAY", + message: "OIDC replay protection unavailable", + }); + }); + + it("fails closed with a 503 when the token lacks bounded replay claims", async () => { + const env: Env = { + ...baseEnv, + NOEMA_RATE_LIMITER: allowRateLimiter(), + NOEMA_OIDC_REPLAY_GUARD: namespaceReturning(acceptGuard), + }; + // job_workflow_ref matches so workflow trust passes, but there is no jti/exp + // pair, so replayClaims returns undefined and the guard is never consulted. + const response = await worker.fetch( + exchangeRequest({ + authorization: `Bearer ${craftToken({ job_workflow_ref: configuredRef })}`, + "cf-connecting-ip": "203.0.113.63", + }), + env, + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_REPLAY", + message: "OIDC replay protection claims unavailable", + }); + }); + + it("reflects a trusted request trace id and fails closed on ambiguous workflow trust", async () => { + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + "cf-connecting-ip": "203.0.113.64", + "x-request-id": "central-trace-01", + }, + }), + { + ...baseEnv, + NOEMA_RATE_LIMITER: allowRateLimiter(), + // A prefix that does not begin with the workflow repository's path makes + // configuredExactWorkflowRef reject it, so the wrapper is misconfigured. + ALLOWED_WORKFLOW_REF_PREFIX: "unrelated/.github/workflows/other.yml@refs/heads/main", + }, + ); + + expect(response.status).toBe(503); + expect(response.headers.get("x-trace-id")).toBe("central-trace-01"); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "Workflow trust configuration unavailable", + trace_id: "central-trace-01", + }); + }); + + it("treats a token with too few segments as unparseable workflow claims", async () => { + const env: Env = { ...baseEnv, NOEMA_RATE_LIMITER: allowRateLimiter() }; + const response = await worker.fetch( + exchangeRequest({ + authorization: "Bearer two.parts", + "cf-connecting-ip": "203.0.113.65", + }), + env, + ); + + // Undecodable claims -> workflow trust allows -> base worker (200 mock) -> + // no replay claims -> fail closed. + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_AUTH_REPLAY", + message: "OIDC replay protection claims unavailable", + }); + }); + + it("treats a non-object token payload as unparseable workflow claims", async () => { + const env: Env = { ...baseEnv, NOEMA_RATE_LIMITER: allowRateLimiter() }; + const response = await worker.fetch( + exchangeRequest({ + authorization: `Bearer ${craftToken([1, 2, 3] as unknown as Record)}`, + "cf-connecting-ip": "203.0.113.66", + }), + env, + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_AUTH_REPLAY", + }); + }); + + it("allows a matching workflow_ref claim without job_workflow_ref", async () => { + const env: Env = { ...baseEnv, NOEMA_RATE_LIMITER: allowRateLimiter() }; + const response = await worker.fetch( + exchangeRequest({ + authorization: `Bearer ${craftToken({ workflow_ref: configuredRef })}`, + "cf-connecting-ip": "203.0.113.67", + }), + env, + ); + + // workflow_ref matches -> trust allows -> base 200 -> no jti -> fail closed. + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_AUTH_REPLAY", + }); + }); + + it("treats an undecodable token payload as unparseable workflow claims", async () => { + const env: Env = { ...baseEnv, NOEMA_RATE_LIMITER: allowRateLimiter() }; + // A valid three-segment shape whose middle segment decodes to bytes that are + // not JSON, exercising the decode catch path. + const badPayload = Buffer.from("definitely not json", "utf8").toString("base64url"); + const token = `${encodeSegment({ alg: "RS256", kid: "test" })}.${badPayload}.signature`; + const response = await worker.fetch( + exchangeRequest({ + authorization: `Bearer ${token}`, + "cf-connecting-ip": "203.0.113.69", + }), + env, + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_AUTH_REPLAY", + }); + }); + + it("allows a token that carries no workflow ref claim at all", async () => { + const env: Env = { ...baseEnv, NOEMA_RATE_LIMITER: allowRateLimiter() }; + const response = await worker.fetch( + exchangeRequest({ + authorization: `Bearer ${craftToken({ sub: "repo:ContextualWisdomLab/.github" })}`, + "cf-connecting-ip": "203.0.113.68", + }), + env, + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_AUTH_REPLAY", + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c233fba26..05e402174 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,12 @@ export default defineConfig({ coverage: { reporter: ["json-summary", "text"], include: ["src/**/*.ts"], + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + }, }, }, });