diff --git a/docs/api-spec.md b/docs/api-spec.md index f8177c7ca..b5ae30bfe 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -54,7 +54,7 @@ `application/json` 요청 body는 UTF-8 wire bytes 기준 최대 **8,192 bytes**다. `Content-Length`가 이 한도를 초과하면 body를 읽지 않고 413으로 거부하며, 길이 헤더가 없거나 신뢰할 수 없는 경우에도 stream을 최대 한도까지만 읽어 chunked 전송 우회를 차단한다. 이 검사는 OIDC/JWKS 조회, GitHub App private-key 사용, GitHub API 호출 전에 수행된다. -`target_repository`가 포함되면 문자열이어야 하며, `owner/repository` 형식과 허용된 organization owner를 만족해야 한다. 객체/배열/null 등 문자열이 아닌 값은 GitHub token 생성 전에 `ERR_VALIDATION_INPUT`으로 거부된다. +`target_repository`가 포함되면 문자열이어야 하며, `owner/repository` 형식과 허용된 organization owner를 만족해야 한다. owner 또는 name 세그먼트가 정확히 `.` 또는 `..`이면 GitHub App private-key 사용과 token 발급 전에 `400 ERR_VALIDATION_INPUT`으로 거부된다. `.github`처럼 점이 포함된 실제 저장소 이름은 허용한다. 객체/배열/null 등 문자열이 아닌 값은 GitHub token 생성 전에 `ERR_VALIDATION_INPUT`으로 거부된다. 호출자는 `ContextualWisdomLab/`만 보내고, 경로 순회 세그먼트·퍼센트 인코딩된 점·추가 슬래시·백슬래시는 보내지 않는다. 공개 OpenAPI `RepositoryLocator`는 lookahead 없이 RE2-안전한 `allOf`/`not` 패턴으로 같은 규칙을 실행하므로, 구매자 도구가 패턴을 컴파일한 뒤 `ContextualWisdomLab/.github`만 보내고 `owner/..`는 보내지 않으면 된다. 설계 근거와 APA 7th 참고문헌은 [`docs/doctoring/repository-path-segment-validation.md`](doctoring/repository-path-segment-validation.md)에 있다. OIDC workflow trust는 전체 ref 문자열의 exact match 정책을 사용한다. - `job_workflow_ref`가 있으면 이를 우선하고, 없으면 `workflow_ref`를 사용한다. diff --git a/docs/api-stability-contract.md b/docs/api-stability-contract.md index 001db5b5b..405fd3cd1 100644 --- a/docs/api-stability-contract.md +++ b/docs/api-stability-contract.md @@ -42,6 +42,7 @@ HTTP 상태 코드는 아래 규칙을 따른다. `/exchange` 401 응답은 `WWW-Authenticate: Bearer realm="noema"` challenge를 포함하며, 인증 누락은 `error="invalid_request"`, 잘못된 토큰은 `error="invalid_token"`으로 구분한다. `/exchange`는 `POST`만 허용하며, 405 응답은 `Allow: POST` 헤더를 포함한다. `target_repository` 타입 오류는 GitHub token 생성 전에 `details.field="target_repository"`, `details.reason`, `details.received_type`로 반환한다. +`target_repository` 문자열이 `owner/repository` 형식이 아니거나 owner/name 세그먼트가 정확히 `.` 또는 `..`이면 GitHub App credential 사용 전에 `400 ERR_VALIDATION_INPUT`으로 거부한다. GitHub installation token 응답의 `token`/`expires_at` 결함은 `ERR_GITHUB_INSTALLATION`과 필드 단위 `details.field`로 반환한다. ## 에러 코드 표준 diff --git a/docs/doctoring/repository-path-segment-validation.md b/docs/doctoring/repository-path-segment-validation.md new file mode 100644 index 000000000..68e65c6ca --- /dev/null +++ b/docs/doctoring/repository-path-segment-validation.md @@ -0,0 +1,71 @@ +# Repository path-segment validation + +## Decision + +`/exchange` accepts an optional `target_repository` string in `owner/name` form. The Worker validates that locator first. Only a surviving `owner/name` may cause a GitHub App private key import. After the key is imported, Noema interpolates the same string into GitHub REST paths such as `/repos/${repository}/installation`. A caller who sends `ContextualWisdomLab/..` or `../noema` would otherwise produce a URL whose `.` / `..` segments are removed during generic URI resolution and no longer name the intended repository. + +The Worker therefore rejects a repository string when either path segment is exactly `.` or `..`, and it does so as `400 ERR_VALIDATION_INPUT` before GitHub App credential work. Names that merely contain a dot, including the real `.github` repository, remain valid. A syntactically valid owner that is not the configured organization still returns `403 ERR_REPO_NOT_ALLOWED`. + +Callers must send `ContextualWisdomLab/` only. Do not send path-traversal segments, percent-encoded dots, or extra slashes. + +## Why this is fail-closed at the public contract + +RFC 3986 defines `.` and `..` as dot segments that a resolver removes while normalizing a path. GitHub's REST path `/repos/{owner}/{repo}/installation` is a URI path, not an opaque token. If Noema forwarded `ContextualWisdomLab/..`, a later URL parser or reverse proxy could resolve it to `/repos/installation` and perform privileged work against the wrong resource. + +The published OpenAPI schema and `docs/api-spec.md` must describe the same rule. A schema that accepts `owner/..` tells an integrator the request is valid while the Worker rejects it, which is a buyer-facing contract gap. The schema therefore uses RE2-safe `allOf` / `not` patterns instead of lookaheads so buyer tooling that compiles OpenAPI `pattern` with RE2 still rejects traversal segments. + +```mermaid +sequenceDiagram + participant Caller + participant Exchange as POST /exchange + participant Validate as validateRepositoryName + participant AppKey as importGithubAppPrivateKey + participant GitHub as api.github.com + + Caller->>Exchange: target_repository + Exchange->>Validate: owner/name + alt segment is . or .. + Validate-->>Caller: 400 ERR_VALIDATION_INPUT + else charset or slash rejected + Validate-->>Caller: 400 ERR_VALIDATION_INPUT + else foreign owner + Validate-->>Caller: 403 ERR_REPO_NOT_ALLOWED + else valid locator + Exchange->>AppKey: PKCS#8 import + AppKey->>GitHub: /repos/{owner}/{repo}/installation + end +``` + +The outbound fetch policy already refuses a repository name that is only `.` or `..` on the installation-token body. Request validation repeats that rule on both owner and name so the private key is never imported for a traversal string, including when tests or a future caller invoke the base Worker without the production fetch wrapper. + +## Verification contract + +Tests must prove: + +- `ContextualWisdomLab/..` and `ContextualWisdomLab/.` return `400 ERR_VALIDATION_INPUT` with zero `api.github.com` egress; +- `../noema` and `./noema` return the same `400` rather than a later owner-allowlist `403`; +- `ContextualWisdomLab/.github` remains a legal name and reaches GitHub App private-key import; +- a foreign owner such as `OtherWisdomLab/noema` still returns `403 ERR_REPO_NOT_ALLOWED`; +- percent-encoded dots, extra slashes, backslashes, and Unicode lookalike dots return `400` with zero PKCS#8 import and zero `api.github.com` egress; +- the published OpenAPI `RepositoryLocator` schema is executed (not only string-compared), contains no lookaheads, rejects `.` / `..` segments, and accepts `.github`; and +- owned production coverage of `validateRepositoryName` and `parseExchangeRequestBody` stays at 100 percent. + +## References + +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform resource identifier (URI): Generic syntax* (RFC 3986). Internet Engineering Task Force. https://doi.org/10.17487/RFC3986 + +Cox, R. (2010). *Regular expression matching in the wild*. https://swtch.com/~rsc/regexp/regexp3.html + +GitHub. (2026). *Creating and managing repositories*. GitHub Docs. https://docs.github.com/en/repositories/creating-and-managing-repositories + +GitHub. (2026). *REST API endpoints for GitHub Apps*. GitHub Docs. https://docs.github.com/en/rest/apps/installations + +Google. (2024). *RE2 syntax*. https://github.com/google/re2/wiki/Syntax + +OpenAPI Initiative. (2021). *OpenAPI specification version 3.1.0*. The Linux Foundation. https://spec.openapis.org/oas/v3.1.0 + +OWASP Foundation. (2021). *A01:2021 – Broken access control*. OWASP Top 10. https://owasp.org/Top10/A01_2021-Broken_Access_Control/ + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST SP 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Wright, A., Andrews, H., Hutton, B., & Dennis, G. (2022). *JSON Schema: A media type for describing JSON documents* (2020-12). https://json-schema.org/draft/2020-12/json-schema-core.html diff --git a/docs/github-api-egress.md b/docs/github-api-egress.md index b8ffe6cf9..8163bde13 100644 --- a/docs/github-api-egress.md +++ b/docs/github-api-egress.md @@ -18,8 +18,12 @@ https://api.github.com userinfo, non-default port, path, query string, fragment, malformed value, 앞뒤 공백, 대소문자 변형 및 lookalike hostname은 거부합니다. URL parser가 `.` 또는 percent-encoded dot segment를 `/`로 정규화하기 전의 원본 문자열도 -검증하므로 root가 아닌 경로가 root로 오인되지 않습니다. 거부된 설정값은 -응답이나 로그에 반영하지 않습니다. +검증하므로 root가 아닌 경로가 root로 오인되지 않습니다. `/exchange`의 +`target_repository`도 owner/name 세그먼트가 정확히 `.` 또는 `..`이면 GitHub App +자격 증명 사용 전에 거부합니다. 거부된 설정값은 응답이나 로그에 반영하지 +않습니다. 저장소 경로 세그먼트 근거는 +[`docs/doctoring/repository-path-segment-validation.md`](doctoring/repository-path-segment-validation.md)를 +따릅니다. GitHub Enterprise Server 또는 별도 API gateway는 암묵적으로 지원하지 않습니다. 이를 지원하려면 exact host, TLS·DNS 소유권, GitHub App tenant, diff --git a/openapi.json b/openapi.json index bb96f5e3d..a8db3d281 100644 --- a/openapi.json +++ b/openapi.json @@ -69,16 +69,7 @@ "description": "Optional target repository selection. When a body is present, its media-type token must be application/json and the wire body is limited to 8,192 bytes before credential-bearing work.", "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "target_repository": { - "type": "string", - "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", - "description": "Authorized owner/repository target." - } - } - } + "schema": { "$ref": "#/components/schemas/ExchangeRequest" } } } }, @@ -259,13 +250,19 @@ "trace_id": { "type": "string" } } }, + "RepositoryLocator": { + "type": "string", + "description": "Authorized owner/repository locator. Each path segment must match [A-Za-z0-9_.-]+ and must not be exactly `.` or `..`. Names such as `.github` remain valid. Constraints use RE2-safe patterns plus JSON Schema not/allOf so buyer tooling that cannot compile lookaheads still rejects traversal segments.", + "allOf": [ + { "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + { "not": { "pattern": "^\\.{1,2}/" } }, + { "not": { "pattern": "/\\.{1,2}$" } } + ] + }, "ExchangeRequest": { "type": "object", "properties": { - "target_repository": { - "type": "string", - "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" - } + "target_repository": { "$ref": "#/components/schemas/RepositoryLocator" } } }, "ExchangeSuccess": { @@ -278,7 +275,7 @@ "required": ["token", "repository", "workflow_ref", "token_expires_at"], "properties": { "token": { "type": "string", "description": "Sensitive short-lived GitHub App installation token. No example value is embedded." }, - "repository": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, + "repository": { "$ref": "#/components/schemas/RepositoryLocator" }, "workflow_ref": { "type": "string" }, "token_expires_at": { "type": "string", "format": "date-time" } } diff --git a/src/index.ts b/src/index.ts index 83ea3b4fb..2c0caecf6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -409,18 +409,23 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise 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(owner) || /^\.{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 { 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 { const contentType = request.headers.get("content-type") || ""; @@ -521,6 +527,7 @@ async function parseExchangeRequestBody(request: Request): Promise { if (!env.NOEMA_OIDC_REPLAY_GUARD) return false; if (typeof claims.jti !== "string" || typeof claims.exp !== "number") { diff --git a/test/coverage-ignore-operational-helpers.test.ts b/test/coverage-ignore-operational-helpers.test.ts index 1a98ca4aa..4a817d1f7 100644 --- a/test/coverage-ignore-operational-helpers.test.ts +++ b/test/coverage-ignore-operational-helpers.test.ts @@ -5,7 +5,7 @@ const source = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8") const ignoredRegions = [...source.matchAll(/\/\* v8 ignore start \*\/[\s\S]*?\/\* v8 ignore stop \*\//g)] .map((match) => match[0]); -describe("operational helper coverage exclusions", () => { +describe("owned production coverage exclusions", () => { it.each([ "jsonResponse", "trustedTraceHeader", @@ -21,6 +21,8 @@ describe("operational helper coverage exclusions", () => { "errorResponse", "withOperationalHeaders", "logRequest", + "validateRepositoryName", + "parseExchangeRequestBody", ])("keeps %s inside measured production coverage", (functionName) => { expect( ignoredRegions.some((region) => region.includes(`function ${functionName}`)), diff --git a/test/credential-request-helper-coverage.test.ts b/test/credential-request-helper-coverage.test.ts new file mode 100644 index 000000000..d0b14ccd3 --- /dev/null +++ b/test/credential-request-helper-coverage.test.ts @@ -0,0 +1,323 @@ +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.each([ + ["name parent segment", "ContextualWisdomLab/.."], + ["name current segment", "ContextualWisdomLab/."], + ["owner parent segment", "../noema"], + ["owner current segment", "./noema"], + ["percent-encoded parent name", "ContextualWisdomLab/%2e%2e"], + ["uppercase percent-encoded parent name", "ContextualWisdomLab/%2E%2E"], + ["extra path segment", "ContextualWisdomLab/noema/extra"], + ["empty name after slash", "ContextualWisdomLab/"], + ["double slash", "ContextualWisdomLab//noema"], + ["backslash separator", "ContextualWisdomLab\\noema"], + ["unicode one-dot-leader name", "ContextualWisdomLab/\u2024\u2024"], + ["unicode fullwidth-dot name", "ContextualWisdomLab/\uFF0E\uFF0E"], + ])("rejects repository URL %s before GitHub App credential work", async (_label, targetRepository) => { + const { token, jwk } = await createSignedJwt("ContextualWisdomLab/.github"); + const upstream = mockOidcDiscovery(jwk); + const importKey = vi.spyOn(crypto.subtle, "importKey"); + + 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: targetRepository }), + }), + 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); + expect(importKey.mock.calls.filter(([format]) => format === "pkcs8")).toHaveLength(0); + }); + + it("allows the real .github repository name and only then imports the GitHub App private key", async () => { + const { token, jwk } = await createSignedJwt("ContextualWisdomLab/.github"); + const upstream = mockOidcDiscovery(jwk); + const importKey = vi.spyOn(crypto.subtle, "importKey"); + + 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.108", + }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/.github" }), + }), + env, + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_INTERNAL", + }); + expect( + upstream.mock.calls.filter(([input]) => String(input).startsWith("https://api.github.com/")), + ).toHaveLength(0); + expect(importKey.mock.calls.filter(([format]) => format === "pkcs8").length).toBeGreaterThan(0); + }); +}); diff --git a/test/openapi-contract.test.ts b/test/openapi-contract.test.ts index 98e7e3240..3d2404eae 100644 --- a/test/openapi-contract.test.ts +++ b/test/openapi-contract.test.ts @@ -12,10 +12,27 @@ function resolveLocalRef(spec: Record, value: Record): return value; } - return ref - .slice(2) - .split("/") - .reduce>((current, segment) => current?.[segment], spec); + return resolveLocalRef( + spec, + ref + .slice(2) + .split("/") + .reduce>((current, segment) => current?.[segment], spec), + ); +} + +function schemaMatchesString(spec: Record, schema: Record, value: string): boolean { + const resolved = resolveLocalRef(spec, schema); + if (Array.isArray(resolved.allOf)) { + return resolved.allOf.every((part: Record) => schemaMatchesString(spec, part, value)); + } + if (resolved.not) { + return !schemaMatchesString(spec, resolved.not, value); + } + if (typeof resolved.pattern === "string") { + return new RegExp(resolved.pattern).test(value); + } + return true; } describe("machine-readable public HTTP contract", () => { @@ -54,14 +71,50 @@ describe("machine-readable public HTTP contract", () => { bearerFormat: "GitHub Actions OIDC JWT", }); expect(exchange.requestBody.required).toBe(false); - expect( - exchange.requestBody.content["application/json"].schema.properties.target_repository.pattern, - ).toBe("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"); + expect(exchange.requestBody.content["application/json"].schema).toEqual({ + $ref: "#/components/schemas/ExchangeRequest", + }); expect(exchange["x-request-body-limit-bytes"]).toBe(8192); expect(exchange.responses["401"].headers["WWW-Authenticate"]).toBeDefined(); expect(exchange.responses["429"].headers["Retry-After"]).toBeDefined(); }); + it("executes the RE2-safe repository locator against realistic owner/name values", async () => { + const spec = await loadOpenApi(); + const locator = spec.components.schemas.RepositoryLocator; + const requestSchema = resolveLocalRef(spec, spec.paths["/exchange"].post.requestBody.content["application/json"].schema); + const successRepository = resolveLocalRef( + spec, + resolveLocalRef(spec, spec.paths["/exchange"].post.responses["200"].content["application/json"].schema) + .properties.data.properties.repository, + ); + + expect(JSON.stringify(locator)).not.toMatch(/\(\?[=!<]/); + expect(requestSchema.properties.target_repository).toEqual({ $ref: "#/components/schemas/RepositoryLocator" }); + expect(successRepository).toEqual(locator); + + const accepted = ["ContextualWisdomLab/.github", "ContextualWisdomLab/noema", "ContextualWisdomLab/a"]; + const rejected = [ + "ContextualWisdomLab/..", + "ContextualWisdomLab/.", + "../noema", + "./noema", + "ContextualWisdomLab/%2e%2e", + "ContextualWisdomLab/noema/extra", + "ContextualWisdomLab//noema", + "ContextualWisdomLab\\noema", + "ContextualWisdomLab/\u2024\u2024", + "ContextualWisdomLab/\uFF0E\uFF0E", + ]; + + for (const value of accepted) { + expect(schemaMatchesString(spec, locator, value), value).toBe(true); + } + for (const value of rejected) { + expect(schemaMatchesString(spec, locator, value), value).toBe(false); + } + }); + it("keeps the common non-cacheable diagnostic headers on every exchange response", async () => { const spec = await loadOpenApi(); const responses = spec.paths["/exchange"].post.responses;