-
Notifications
You must be signed in to change notification settings - Fork 0
test(coverage): measure bounded credential request helpers #397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2b59db4
4d8b42f
58d4f61
9c193ac
4c3af64
6124658
3d4f87b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -409,18 +409,23 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise<JwtPayload> | |
| throw new ApiError("ERR_OIDC_VERIFICATION", 401, "OIDC token verification failed"); | ||
| } | ||
| } | ||
| /* v8 ignore stop */ | ||
|
|
||
| function validateRepositoryName(repository: string, env: Env): string { | ||
| if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { | ||
| throw new ApiError("ERR_VALIDATION_INPUT", 400, "target_repository is not a valid owner/name repository"); | ||
| } | ||
| const [owner] = repository.split("/", 1); | ||
| const [owner, name] = repository.split("/", 2); | ||
| if (/^\.{1,2}$/.test(name)) { | ||
| throw new ApiError("ERR_VALIDATION_INPUT", 400, "target_repository is not a valid owner/name repository"); | ||
| } | ||
| if (owner !== env.ALLOWED_REPOSITORY_OWNER) { | ||
| throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "target_repository owner is not allowed"); | ||
| } | ||
| return repository; | ||
| } | ||
|
|
||
| /* v8 ignore start */ | ||
| async function importGithubAppPrivateKey(pem: string): Promise<CryptoKey> { | ||
| const body = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, ""); | ||
| const der = base64UrlDecode(body.replace(/\+/g, "-").replace(/\//g, "_")); | ||
|
|
@@ -507,6 +512,7 @@ async function createInstallationToken(repository: string, env: Env): Promise<In | |
| expires_at: String(token.expires_at), | ||
| }; | ||
| } | ||
| /* v8 ignore stop */ | ||
|
|
||
| async function parseExchangeRequestBody(request: Request): Promise<ExchangeRequestBody> { | ||
| const contentType = request.headers.get("content-type") || ""; | ||
|
|
@@ -521,6 +527,7 @@ async function parseExchangeRequestBody(request: Request): Promise<ExchangeReque | |
| return body as ExchangeRequestBody; | ||
| } | ||
|
|
||
| /* v8 ignore start */ | ||
| async function claimVerifiedOidcUsage(claims: JwtPayload, env: Env): Promise<boolean> { | ||
| if (!env.NOEMA_OIDC_REPLAY_GUARD) return false; | ||
| if (typeof claims.jti !== "string" || typeof claims.exp !== "number") { | ||
|
|
@@ -710,4 +717,4 @@ export default { | |
| return withOperationalHeaders(response, traceId, latency_ms); | ||
| } | ||
| }, | ||
| }; | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This commit strips the trailing newline ( |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,279 @@ | ||
| import { 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 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-before-request-validation", | ||
| NOEMA_RATE_LIMIT_PER_MINUTE: "1000", | ||
| }; | ||
|
|
||
| function encodeSegment(value: unknown): string { | ||
| return Buffer.from(JSON.stringify(value)).toString("base64url"); | ||
| } | ||
|
|
||
| function encodeBytes(bytes: ArrayBuffer): string { | ||
| return Buffer.from(bytes).toString("base64url"); | ||
| } | ||
|
|
||
| async function createSignedJwt(repository: string) { | ||
| 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"], | ||
| ); | ||
| const kid = `credential-request-${crypto.randomUUID()}`; | ||
| const now = Math.floor(Date.now() / 1000); | ||
| const payload = { | ||
| iss: env.ALLOWED_ISSUER, | ||
| aud: env.ALLOWED_AUDIENCE, | ||
| repository_owner: env.ALLOWED_REPOSITORY_OWNER, | ||
| repository, | ||
| job_workflow_ref: configuredRef, | ||
| sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", | ||
| exp: now + 300, | ||
| nbf: now - 30, | ||
| iat: now - 30, | ||
| }; | ||
| const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); | ||
| const body = encodeSegment(payload); | ||
| const signature = await crypto.subtle.sign( | ||
| "RSASSA-PKCS1-v1_5", | ||
| keyPair.privateKey, | ||
| new TextEncoder().encode(`${header}.${body}`), | ||
| ); | ||
| const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); | ||
| return { | ||
| token: `${header}.${body}.${encodeBytes(signature)}`, | ||
| jwk: { ...publicJwk, kid, kty: "RSA" }, | ||
| }; | ||
| } | ||
|
|
||
| function mockOidcDiscovery(jwk: JsonWebKey & { kid: string; kty: string }) { | ||
| const upstream = 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 new Response("unexpected privileged egress", { status: 500 }); | ||
| }); | ||
| return upstream; | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe("credential request helper coverage through the public worker", () => { | ||
| it("rejects malformed JSON after verified OIDC without reaching GitHub App egress", async () => { | ||
| const { token, jwk } = await createSignedJwt("ContextualWisdomLab/.github"); | ||
| const upstream = mockOidcDiscovery(jwk); | ||
|
|
||
| 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.101", | ||
| }, | ||
| body: "{", | ||
| }), | ||
| env, | ||
| ); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| await expect(response.json()).resolves.toMatchObject({ | ||
| ok: false, | ||
| error_code: "ERR_VALIDATION_INPUT", | ||
| message: "Malformed JSON request body", | ||
| }); | ||
| expect( | ||
| upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), | ||
| ).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("treats a non-object JSON body as empty before repository syntax validation", async () => { | ||
| const { token, jwk } = await createSignedJwt("invalid-repository-name"); | ||
| const upstream = mockOidcDiscovery(jwk); | ||
|
|
||
| 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.102", | ||
| }, | ||
| body: "null", | ||
| }), | ||
| env, | ||
| ); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| await expect(response.json()).resolves.toMatchObject({ | ||
| ok: false, | ||
| error_code: "ERR_VALIDATION_INPUT", | ||
| message: "target_repository is not a valid owner/name repository", | ||
| }); | ||
| expect( | ||
| upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), | ||
| ).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("treats a truthy primitive JSON body as empty before repository syntax validation", async () => { | ||
| const { token, jwk } = await createSignedJwt("invalid-repository-name"); | ||
| const upstream = mockOidcDiscovery(jwk); | ||
|
|
||
| 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.106", | ||
| }, | ||
| body: "7", | ||
| }), | ||
| env, | ||
| ); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| await expect(response.json()).resolves.toMatchObject({ | ||
| ok: false, | ||
| error_code: "ERR_VALIDATION_INPUT", | ||
| message: "target_repository is not a valid owner/name repository", | ||
| }); | ||
| expect( | ||
| upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), | ||
| ).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("treats a non-JSON body as empty before repository syntax validation", async () => { | ||
| const { token, jwk } = await createSignedJwt("invalid-repository-name"); | ||
| const upstream = mockOidcDiscovery(jwk); | ||
|
|
||
| const response = await worker.fetch( | ||
| new Request("https://noema.example/exchange", { | ||
| method: "POST", | ||
| headers: { | ||
| authorization: `Bearer ${token}`, | ||
| "content-type": "text/plain", | ||
| "cf-connecting-ip": "203.0.113.103", | ||
| }, | ||
| body: "ignored", | ||
| }), | ||
| env, | ||
| ); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| await expect(response.json()).resolves.toMatchObject({ | ||
| ok: false, | ||
| error_code: "ERR_VALIDATION_INPUT", | ||
| message: "target_repository is not a valid owner/name repository", | ||
| }); | ||
| expect( | ||
| upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), | ||
| ).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("treats a missing content type as a non-JSON body without privileged egress", async () => { | ||
| const { token, jwk } = await createSignedJwt("invalid-repository-name"); | ||
| const upstream = mockOidcDiscovery(jwk); | ||
|
|
||
| const response = await worker.fetch( | ||
| new Request("https://noema.example/exchange", { | ||
| method: "POST", | ||
| headers: { | ||
| authorization: `Bearer ${token}`, | ||
| "cf-connecting-ip": "203.0.113.107", | ||
| }, | ||
| }), | ||
| env, | ||
| ); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| await expect(response.json()).resolves.toMatchObject({ | ||
| ok: false, | ||
| error_code: "ERR_VALIDATION_INPUT", | ||
| message: "target_repository is not a valid owner/name repository", | ||
| }); | ||
| expect( | ||
| upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), | ||
| ).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("rejects a syntactically valid repository owned outside the configured organization", async () => { | ||
| const { token, jwk } = await createSignedJwt("ContextualWisdomLab/.github"); | ||
| const upstream = mockOidcDiscovery(jwk); | ||
|
|
||
| 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.104", | ||
| }, | ||
| body: JSON.stringify({ target_repository: "OtherWisdomLab/noema" }), | ||
| }), | ||
| env, | ||
| ); | ||
|
|
||
| expect(response.status).toBe(403); | ||
| await expect(response.json()).resolves.toMatchObject({ | ||
| ok: false, | ||
| error_code: "ERR_REPO_NOT_ALLOWED", | ||
| message: "target_repository owner is not allowed", | ||
| }); | ||
| expect( | ||
| upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), | ||
| ).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("rejects repository URL dot segments before GitHub App credential work", async () => { | ||
| const { token, jwk } = await createSignedJwt("ContextualWisdomLab/.github"); | ||
| const upstream = mockOidcDiscovery(jwk); | ||
|
|
||
| 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.105", | ||
| }, | ||
| body: JSON.stringify({ target_repository: "ContextualWisdomLab/.." }), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the only dot-segment case. Add |
||
| }), | ||
| env, | ||
| ); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| await expect(response.json()).resolves.toMatchObject({ | ||
| ok: false, | ||
| error_code: "ERR_VALIDATION_INPUT", | ||
| message: "target_repository is not a valid owner/name repository", | ||
| }); | ||
| expect( | ||
| upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), | ||
| ).toHaveLength(0); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This only rejects a
./..name.../noemaand./noemastill pass the owner/name regex and fall through to the org allowlist as403 ERR_REPO_NOT_ALLOWED.That is fail-closed today, but the published OpenAPI pattern still accepts those strings, and
#/repos/${repository}/installationis the path this helper is supposed to keep off GitHub App work.#399 rejects both segments with the same
400 ERR_VALIDATION_INPUTand publishes^(?!\.{1,2}/)[A-Za-z0-9_.-]+/(?!\.{1,2}$)[A-Za-z0-9_.-]+$. Keep.githuballowed.