-
Notifications
You must be signed in to change notification settings - Fork 0
fix(github): canonicalize accepted API base URLs #430
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
07c3900
0a5a659
652c660
e774d18
2344eaf
51913c1
90153e8
390421f
261a22f
b1665d8
ae43758
f096ebc
6ecc8f3
be991e1
35b9bd7
c05305b
8ecccae
05ea61c
8da1e50
dd0794c
e6544ae
13d9e76
d7fa067
69b9a7f
4b2ef4b
23cc641
5450804
f751f7c
1d6cf24
e7b5c87
f0425ce
760b61c
a09a21e
2b36571
589c977
04d8c92
07c3bdb
6a26142
c918efd
339d752
2fd2395
f13e73d
934fe0a
eee3a9d
c5d9b76
257063a
9e6f766
9e5925b
cabdf67
92a954c
8e95bad
5ef4b55
7484ffd
a454c83
3a5464e
2c8573e
a8fb0ee
77c97b4
fbff0af
9a90617
075f0b6
b90e12e
de1388e
b7e8235
9988d75
b61113c
77895d0
ea81c9e
cdbdc5f
dd619bb
e67ae24
fe6522f
c83dc18
ecd59c1
04e1ceb
fcd30ea
ce5d327
58a4b13
03a6cec
2a65dc9
5382b9e
2274772
bf3e60f
b11b2c5
23b4dc4
74341bb
e9e1116
4b5ec4e
add4243
0f10cac
5ff6612
7affdc5
ca9a587
697728c
be569dd
eff0b3a
3c9ede7
4f957d2
054e671
bbf8fcf
8e4ebc3
9081b5e
233aa7a
a23b085
1552405
00ad0aa
9a51491
9bddecc
fecd911
4df846e
2cebfe2
2a8e3ce
31062bb
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 |
|---|---|---|
|
|
@@ -31,7 +31,9 @@ type JwtPayload = { | |
| iss?: string; | ||
| aud?: string | string[]; | ||
| repository?: string; | ||
| repository_id?: string; | ||
| repository_owner?: string; | ||
| repository_owner_id?: string; | ||
| workflow_ref?: string; | ||
| workflow_sha?: string; | ||
| job_workflow_ref?: string; | ||
|
|
@@ -141,7 +143,14 @@ const errorHints: Record<ErrorCode, string> = { | |
| const trustedHeaderValuePattern = /^[A-Za-z0-9._:-]+$/; | ||
| const clientIdentifierPattern = /^[A-Za-z0-9.:%_,-]+$/; | ||
| const exactWorkflowSourceShaPattern = /^[0-9a-f]{40}$/; | ||
| const githubInstallationTokenExpiryPattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?Z$/; | ||
| const expectedRepositoryOwnerId = "295022177"; | ||
| const expectedRepositoryIds = new Map<string, string>([ | ||
| ["ContextualWisdomLab/noema", "1285107801"], | ||
| ["ContextualWisdomLab/.github", "1274066402"], | ||
| ]); | ||
|
Comment on lines
+147
to
+151
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. 🔍 Hardcoded owner/repository IDs become mandatory 403 gates
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| const maxTrustedHeaderLength = 128; | ||
| const maxInstallationTokenLifetimeMs = 65 * 60_000; | ||
|
|
||
| function jsonResponse(body: StandardErrorResponse | StandardSuccessResponse<unknown>, status = 200): Response { | ||
| return new Response(JSON.stringify(body), { | ||
|
|
@@ -191,6 +200,18 @@ function valueType(value: unknown): string { | |
| return typeof value; | ||
| } | ||
|
|
||
| function canonicalGithubInstallationTokenExpiry(value: string, parsedMs: number): boolean { | ||
| const match = value.match(githubInstallationTokenExpiryPattern); | ||
| if (!match) return false; | ||
| const milliseconds = (match[7] ?? "").padEnd(3, "0"); | ||
| const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}.${milliseconds}Z`; | ||
| try { | ||
| return new Date(parsedMs).toISOString() === normalized; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function requestClientKey(request: Request, route: string): string { | ||
| const client = request.headers.get("cf-connecting-ip") | ||
| || request.headers.get("x-real-ip") | ||
|
|
@@ -425,6 +446,20 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise<JwtPayload> | |
| const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; | ||
| if (!audiences.includes(env.ALLOWED_AUDIENCE)) throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC audience is not allowed"); | ||
| if (payload.repository_owner !== env.ALLOWED_REPOSITORY_OWNER) throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner is not allowed"); | ||
| if ( | ||
| payload.repository_owner_id !== undefined | ||
| && payload.repository_owner_id !== expectedRepositoryOwnerId | ||
| ) { | ||
| throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository owner identity is not allowed"); | ||
| } | ||
| const expectedRepositoryId = payload.repository ? expectedRepositoryIds.get(payload.repository) : undefined; | ||
| if ( | ||
| expectedRepositoryId !== undefined | ||
| && payload.repository_id !== undefined | ||
| && payload.repository_id !== expectedRepositoryId | ||
| ) { | ||
| throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository identity is not allowed"); | ||
| } | ||
|
|
||
| const workflowRef = payload.job_workflow_ref || payload.workflow_ref || ""; | ||
| if (workflowRef !== env.ALLOWED_WORKFLOW_REF_PREFIX) { | ||
|
|
@@ -451,10 +486,19 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise<JwtPayload> | |
| { match_policy: "exact-ref-and-source-sha" }, | ||
| ); | ||
| } | ||
| if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || !Number.isFinite(payload.nbf))) { | ||
| throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC not-before claim is invalid"); | ||
| } | ||
| if (typeof payload.nbf === "number" && payload.nbf > now + 30) { | ||
| throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC token is not valid yet"); | ||
| } | ||
| if (typeof payload.exp !== "number" || payload.exp < now - 30) { | ||
| if (payload.iat !== undefined && (typeof payload.iat !== "number" || !Number.isFinite(payload.iat))) { | ||
| throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC issued-at claim is invalid"); | ||
| } | ||
| if (typeof payload.iat === "number" && payload.iat > now + 30) { | ||
| throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC token was issued in the future"); | ||
| } | ||
| if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp) || payload.exp < now - 30) { | ||
| throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC token is expired"); | ||
| } | ||
|
|
||
|
|
@@ -501,8 +545,8 @@ type GitHubJsonRequestInit = RequestInit & { | |
| headers: Record<string, string>; | ||
| }; | ||
|
|
||
| async function githubJson(path: string, init: GitHubJsonRequestInit, env: Env): Promise<any> { | ||
| const response = await fetch(`${env.GITHUB_API_BASE}${path}`, { | ||
| async function githubJson(path: string, init: GitHubJsonRequestInit, env: Env): Promise<Record<string, unknown>> { | ||
| const response = await fetch(new URL(path, env.GITHUB_API_BASE), { | ||
| ...init, | ||
| headers: { | ||
| accept: "application/vnd.github+json", | ||
|
|
@@ -520,11 +564,30 @@ async function githubJson(path: string, init: GitHubJsonRequestInit, env: Env): | |
| } | ||
| throw new ApiError("ERR_GITHUB_API", response.status >= 400 ? 400 : 500, "GitHub API request failed"); | ||
| } | ||
| return response.json(); | ||
| let value: unknown; | ||
| try { | ||
| value = await response.json(); | ||
| } catch { | ||
| throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned malformed JSON"); | ||
| } | ||
| if (!value || typeof value !== "object" || Array.isArray(value)) { | ||
| throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid JSON shape"); | ||
| } | ||
| return value as Record<string, unknown>; | ||
| } | ||
|
|
||
| async function resolveInstallationId(appJwt: string, repository: string, env: Env): Promise<string> { | ||
| if (env.GITHUB_APP_INSTALLATION_ID) return env.GITHUB_APP_INSTALLATION_ID; | ||
| if (env.GITHUB_APP_INSTALLATION_ID) { | ||
| const configuredInstallationId = env.GITHUB_APP_INSTALLATION_ID; | ||
| if (!/^[1-9]\d*$/.test(configuredInstallationId)) { | ||
| throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id configuration is invalid"); | ||
| } | ||
| const numericInstallationId = Number(configuredInstallationId); | ||
| if (!Number.isSafeInteger(numericInstallationId) || String(numericInstallationId) !== configuredInstallationId) { | ||
| throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id configuration is invalid"); | ||
| } | ||
| return configuredInstallationId; | ||
| } | ||
| const now = Date.now(); | ||
| const cacheKey = `${env.GITHUB_API_BASE}:${env.GITHUB_APP_ID}:${repository}`; | ||
| const cached = installationIdCache.get(cacheKey); | ||
|
|
@@ -538,7 +601,16 @@ async function resolveInstallationId(appJwt: string, repository: string, env: En | |
| const installation = await githubJson(`/repos/${repository}/installation`, { | ||
| headers: { authorization: `Bearer ${appJwt}` }, | ||
| }, env); | ||
| if (!installation.id) throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id was not found"); | ||
| if (installation.id === undefined || installation.id === null) { | ||
| throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub App installation id was not found"); | ||
| } | ||
| if ( | ||
| typeof installation.id !== "number" | ||
| || !Number.isSafeInteger(installation.id) | ||
| || installation.id <= 0 | ||
| ) { | ||
| throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid installation response"); | ||
| } | ||
| const installationId = String(installation.id); | ||
| installationIdCache.set(cacheKey, { | ||
| value: installationId, | ||
|
|
@@ -555,21 +627,44 @@ async function createInstallationToken(repository: string, env: Env): Promise<In | |
| headers: { authorization: `Bearer ${appJwt}` }, | ||
| body: JSON.stringify({ repositories: [repository.split("/", 2)[1]], permissions: { pull_requests: "write", contents: "read", checks: "read" } }), | ||
| }, env); | ||
| if (!token.token) { | ||
| if (token.token === undefined || token.token === null || token.token === "") { | ||
| throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub installation token response was empty", { | ||
| field: "token", | ||
| reason: "required", | ||
| }); | ||
| } | ||
| if (!token.expires_at || Number.isNaN(Date.parse(String(token.expires_at)))) { | ||
| if (typeof token.token !== "string") { | ||
| throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid installation-token response"); | ||
| } | ||
| if (token.expires_at === undefined || token.expires_at === null || token.expires_at === "") { | ||
| throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub installation token response did not include a valid expires_at", { | ||
| field: "expires_at", | ||
| reason: "must be a valid timestamp", | ||
| }); | ||
| } | ||
| if (typeof token.expires_at !== "string") { | ||
| throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid installation-token response"); | ||
| } | ||
| const expiresAtMs = Date.parse(token.expires_at); | ||
| if (Number.isNaN(expiresAtMs)) { | ||
| throw new ApiError("ERR_GITHUB_INSTALLATION", 500, "GitHub installation token response did not include a valid expires_at", { | ||
| field: "expires_at", | ||
| reason: "must be a valid timestamp", | ||
| }); | ||
| } | ||
| if (!canonicalGithubInstallationTokenExpiry(token.expires_at, expiresAtMs)) { | ||
| throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned invalid installation-token expiry"); | ||
| } | ||
| const nowMs = Date.now(); | ||
| if (expiresAtMs <= nowMs) { | ||
| throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned expired installation-token response"); | ||
| } | ||
| if (expiresAtMs > nowMs + maxInstallationTokenLifetimeMs) { | ||
| throw new ApiError("ERR_GITHUB_API", 502, "GitHub API returned implausible installation-token expiry"); | ||
| } | ||
| return { | ||
| token: String(token.token), | ||
| expires_at: String(token.expires_at), | ||
| token: token.token, | ||
| expires_at: token.expires_at, | ||
| }; | ||
| } | ||
|
|
||
|
|
||
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.
📝 Info: Bearer envelope now returns 400 instead of 401 for whitespace-only tokens
isBoundedOidcBearernow treats any value starting withBearer(then whitespace or end) as a JWT envelope that must match^Bearer\s+(\S+)$. Whitespace-only and embedded-whitespace Bearer values that previously reached the 401 missing-token path now fail closed with 400ERR_TOKEN_MALFORMED. The prefilter test was updated to match; any client depending on the old 401 sees a changed status and error code.(Refers to this code)
Was this helpful? React with 👍 or 👎 to provide feedback.