Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 요구사항을 일치시켰다.
Expand Down
89 changes: 89 additions & 0 deletions test/distributed-rate-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
110 changes: 110 additions & 0 deletions test/oidc-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
73 changes: 73 additions & 0 deletions test/worker-defensive-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../src/rate-limit")>();
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" },
});
});
});
101 changes: 101 additions & 0 deletions test/worker-defensive-replay.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../src/oidc-replay")>();
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<Response>,
): 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",
});
});
});
Loading
Loading