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
2 changes: 1 addition & 1 deletion docs/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repository>`만 보내고, 경로 순회 세그먼트·퍼센트 인코딩된 점·추가 슬래시·백슬래시는 보내지 않는다. 공개 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`를 사용한다.
Expand Down
1 change: 1 addition & 0 deletions docs/api-stability-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`로 반환한다.

## 에러 코드 표준
Expand Down
71 changes: 71 additions & 0 deletions docs/doctoring/repository-path-segment-validation.md
Original file line number Diff line number Diff line change
@@ -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/<repository>` 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
8 changes: 6 additions & 2 deletions docs/github-api-egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 12 additions & 15 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
},
Expand Down Expand Up @@ -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": {
Expand All @@ -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" }
}
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(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<CryptoKey> {
const body = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
const der = base64UrlDecode(body.replace(/\+/g, "-").replace(/\//g, "_"));
Expand Down Expand Up @@ -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") || "";
Expand All @@ -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") {
Expand Down
4 changes: 3 additions & 1 deletion test/coverage-ignore-operational-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}`)),
Expand Down
Loading
Loading