Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
113 commits
Select commit Hold shift + click to select a range
07c3900
test(github): reproduce trailing-slash API base failure
seonghobae Aug 19, 2026
0a5a659
fix(github): canonicalize accepted API root URLs
seonghobae Aug 19, 2026
652c660
test(github): cover every accepted API root spelling
seonghobae Aug 19, 2026
e774d18
test(github): reproduce malformed success JSON classification
seonghobae Aug 19, 2026
2344eaf
fix(github): classify malformed success JSON upstream
seonghobae Aug 19, 2026
51913c1
test(github): reproduce invalid success JSON shapes
seonghobae Aug 19, 2026
90153e8
fix(github): reject non-object success JSON
seonghobae Aug 19, 2026
390421f
test(github): reject malformed installation fields
seonghobae Aug 19, 2026
261a22f
fix(github): validate installation response fields
seonghobae Aug 19, 2026
b1665d8
merge main into fix/github-api-base-normalization
seonghobae Aug 19, 2026
ae43758
test(egress): fail closed on response stream errors
seonghobae Aug 19, 2026
f096ebc
fix(egress): classify response stream failures
seonghobae Aug 19, 2026
6ecc8f3
test(egress): reject without awaiting cleanup
seonghobae Aug 19, 2026
be991e1
fix(egress): never await rejected-body cleanup
seonghobae Aug 19, 2026
35b9bd7
merge(main): converge GitHub API hardening on current protected base
seonghobae Aug 19, 2026
c05305b
merge(main): converge GitHub API hardening after #433
seonghobae Aug 19, 2026
8ecccae
test(github): reject expired installation credentials
seonghobae Aug 19, 2026
05ea61c
fix(github): reject expired installation credentials
seonghobae Aug 19, 2026
8da1e50
test(github): reject implausibly long-lived installation tokens
seonghobae Aug 19, 2026
dd0794c
fix(github): bound installation token lifetime
seonghobae Aug 19, 2026
e6544ae
test(github): keep successful token fixtures within lifetime
seonghobae Aug 19, 2026
13d9e76
test(github): use bounded success token expiry
seonghobae Aug 19, 2026
d7fa067
test(github): bound replay success token lifetime
seonghobae Aug 19, 2026
69b9a7f
fix(ci): restore canonical source newline
seonghobae Aug 20, 2026
4b2ef4b
test(github): keep exchange success expiry current
seonghobae Aug 20, 2026
23cc641
test(github): reject invalid configured installation ids
seonghobae Aug 20, 2026
5450804
fix(github): validate configured installation id
seonghobae Aug 20, 2026
f751f7c
test(github): align configured installation-id contract
seonghobae Aug 20, 2026
1d6cf24
test(readiness): reject unsafe GitHub numeric identifiers
seonghobae Aug 20, 2026
e7b5c87
fix(readiness): bound GitHub numeric identifiers
seonghobae Aug 20, 2026
f0425ce
test(github): reject invalid App ids before egress
seonghobae Aug 20, 2026
760b61c
test(github): bind App id rejection to public edge
seonghobae Aug 20, 2026
a09a21e
fix(github): reject invalid App ids at public edge
seonghobae Aug 20, 2026
2b36571
merge main into fix/github-api-base-normalization after #435
seonghobae Aug 20, 2026
589c977
test(exchange): reject oversized body without awaiting cleanup
seonghobae Aug 20, 2026
04d8c92
fix(exchange): never await rejected-body cleanup
seonghobae Aug 20, 2026
07c3bdb
test(exchange): require early rejection body cleanup
seonghobae Aug 20, 2026
6a26142
fix(exchange): release rejected request bodies
seonghobae Aug 20, 2026
c918efd
test(egress): classify credential transport failures
seonghobae Aug 20, 2026
339d752
fix(egress): fail closed on transport rejection
seonghobae Aug 20, 2026
2fd2395
fix(egress): scope transport failure to credentials
seonghobae Aug 20, 2026
f13e73d
test(readiness): require immutable workflow source coherence
seonghobae Aug 20, 2026
934fe0a
fix(readiness): bind immutable workflow ref to source sha
seonghobae Aug 20, 2026
eee3a9d
test(github): reject invalid installation id at request edge
seonghobae Aug 20, 2026
c5d9b76
fix(github): reject invalid installation id at request edge
seonghobae Aug 20, 2026
257063a
test(github): bound installation id in credential egress path
seonghobae Aug 20, 2026
9e6f766
fix(github): bound installation id in credential egress path
seonghobae Aug 20, 2026
9e5925b
test(egress): clean up blocked redirect bodies
seonghobae Aug 20, 2026
cabdf67
fix(egress): clean up blocked redirect bodies
seonghobae Aug 20, 2026
92a954c
test(github): clean up outbound read failures
seonghobae Aug 20, 2026
8e95bad
fix(github): clean up outbound read failures
seonghobae Aug 20, 2026
5ef4b55
test(github): clean up exchange read failures
seonghobae Aug 20, 2026
7484ffd
fix(github): clean up exchange read failures
seonghobae Aug 20, 2026
a454c83
Merge protected main into GitHub hardening
seonghobae Aug 20, 2026
3a5464e
test(oidc): reject non-numeric not-before claims
seonghobae Aug 21, 2026
2c8573e
fix(oidc): reject non-numeric not-before claims
seonghobae Aug 21, 2026
a8fb0ee
test(github): reject impossible token expiry
seonghobae Aug 21, 2026
77c97b4
fix(github): validate token expiry calendar
seonghobae Aug 21, 2026
fbff0af
test(oidc): reject non-finite NumericDate claims
seonghobae Aug 21, 2026
9a90617
fix(oidc): require finite NumericDate claims
seonghobae Aug 21, 2026
075f0b6
test(oidc): reject invalid issued-at claims
seonghobae Aug 21, 2026
b90e12e
fix(oidc): validate issued-at claims
seonghobae Aug 21, 2026
de1388e
test(oidc): bind repository owner immutable id
seonghobae Aug 21, 2026
b7e8235
fix(oidc): reject mismatched repository owner identity
seonghobae Aug 21, 2026
9988d75
test(oidc): bind repository immutable identity
seonghobae Aug 21, 2026
b61113c
fix(oidc): bind repository immutable identity
seonghobae Aug 21, 2026
77895d0
test(oidc): preserve central workflow repository identity
seonghobae Aug 21, 2026
ea81c9e
fix(oidc): scope immutable repository id to Noema claim
seonghobae Aug 21, 2026
cdbdc5f
test(oidc): bind central workflow repository identity
seonghobae Aug 21, 2026
dd619bb
fix(oidc): bind trusted repository identity pairs
seonghobae Aug 21, 2026
e67ae24
merge main into GitHub App hardening
seonghobae Aug 21, 2026
fe6522f
merge main into fix/github-api-base-normalization
seonghobae Aug 21, 2026
c83dc18
merge protected main into fix/github-api-base-normalization
seonghobae Aug 21, 2026
ecd59c1
merge(main): converge GitHub credential boundary on current protected…
seonghobae Aug 21, 2026
04e1ceb
merge(main): converge GitHub credential hardening on protected main
seonghobae Aug 21, 2026
fcd30ea
merge(main): converge GitHub credential hardening on protected releas…
seonghobae Aug 21, 2026
ce5d327
test(oidc): require current central workflow source
seonghobae Aug 21, 2026
58a4b13
fix(oidc): trust current central workflow source
seonghobae Aug 21, 2026
03a6cec
test(oidc): reject bearer credential whitespace at edge
seonghobae Aug 21, 2026
2a65dc9
fix(oidc): reject malformed bearer whitespace at edge
seonghobae Aug 21, 2026
5382b9e
style(core): restore canonical newline
seonghobae Aug 21, 2026
2274772
test(oidc): require current central workflow source
seonghobae Aug 21, 2026
bf3e60f
fix(oidc): roll forward trusted workflow source
seonghobae Aug 21, 2026
b11b2c5
test(oidc): require current central workflow source SHA
seonghobae Aug 21, 2026
23b4dc4
fix(oidc): roll forward central workflow source SHA
seonghobae Aug 21, 2026
74341bb
test(ci): require observable release verifier boundaries
seonghobae Aug 21, 2026
e9e1116
fix(ci): expose release verifier failure boundaries
seonghobae Aug 21, 2026
4b5ec4e
test(ci): align reproducibility contract with observable release gates
seonghobae Aug 21, 2026
add4243
test(oidc): require latest central workflow source SHA
seonghobae Aug 21, 2026
0f10cac
fix(oidc): follow latest central workflow source SHA
seonghobae Aug 21, 2026
5ff6612
test(ci): require bounded release-test diagnostics
seonghobae Aug 21, 2026
7affdc5
fix(ci): bound release-test diagnostic output
seonghobae Aug 21, 2026
ca9a587
fix(ci): restore canonical source newline
seonghobae Aug 21, 2026
697728c
fix(ci): preserve source formatting while restoring newline
seonghobae Aug 21, 2026
be569dd
test(oidc): require current central workflow source
seonghobae Aug 22, 2026
eff0b3a
fix(oidc): roll forward central workflow source
seonghobae Aug 22, 2026
3c9ede7
test(ci): require bounded failure diagnostics
seonghobae Aug 22, 2026
4f957d2
fix(ci): bound release-test failure diagnostics
seonghobae Aug 22, 2026
054e671
test(oidc): align Bearer whitespace with bounded envelope
seonghobae Aug 22, 2026
bbf8fcf
test(readiness): keep immutable workflow ref and SHA coherent
seonghobae Aug 22, 2026
8e4ebc3
test(github): satisfy earlier App-id trust gate in egress fixtures
seonghobae Aug 22, 2026
9081b5e
test(exchange): satisfy App-id preflight in egress-boundary fixture
seonghobae Aug 22, 2026
233aa7a
test(exchange): cover synchronous cleanup failure
seonghobae Aug 22, 2026
a23b085
test(github): cover expiry defensive branches
seonghobae Aug 22, 2026
1552405
test(exchange): cover bodyless early rejection cleanup
seonghobae Aug 22, 2026
00ad0aa
test(github): cover noncanonical token expiry format
seonghobae Aug 22, 2026
9a51491
test(exchange): align bodyless request coverage
seonghobae Aug 22, 2026
9bddecc
test(oidc): require current central workflow source
seonghobae Aug 22, 2026
fecd911
fix(oidc): trust current central workflow source
seonghobae Aug 22, 2026
4df846e
test(exchange): cover disappearing body cleanup race
seonghobae Aug 22, 2026
2cebfe2
test(exchange): cover rejected cleanup promise
seonghobae Aug 22, 2026
2a8e3ce
test(oidc): require current central workflow source
seonghobae Aug 22, 2026
31062bb
fix(oidc): roll forward central workflow source
seonghobae Aug 22, 2026
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
27 changes: 25 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,31 @@ jobs:
- name: install
run: npm ci --legacy-peer-deps=false --install-links=false

- name: release verify
run: npm run release:verify
- name: release typecheck
run: npm run typecheck

- name: release tests
shell: bash
run: |
set -euo pipefail
log="$RUNNER_TEMP/noema-release-tests.log"
if npm run test -- --reporter=dot >"$log" 2>&1; then
exit 0
fi
tail -c 32768 "$log" | tail -n 160
exit 1

- name: release security scan
run: npm run security:scan

- name: release KPI verification
run: npm run kpi:verify

- name: release acquisition manifest
run: npm run acquisition:manifest

- name: release acquisition integrity
run: npm run acquisition:integrity

- name: refuse pull-request base drift after verification
if: github.event_name == 'pull_request'
Expand Down
68 changes: 59 additions & 9 deletions src/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const TRUSTED_GITHUB_API_ORIGIN = "https://api.github.com";
const trustedGithubApiBasePattern = /^https:\/\/api\.github\.com(?::443)?\/?$/;
const trustedTracePattern = /^[A-Za-z0-9._:-]+$/;
const jwtSegmentPattern = /^[A-Za-z0-9_-]+$/;
const positiveDecimalPattern = /^[1-9][0-9]*$/;
const MAX_TRACE_LENGTH = 128;
const MAX_AUTHORIZATION_HEADER_LENGTH = 16_384;
const MAX_JWT_HEADER_SEGMENT_LENGTH = 2_048;
Expand All @@ -29,7 +30,11 @@ const MAX_EXCHANGE_JSON_BODY_BYTES = 8_192;
type EgressFailure = {
hint: string;
outcome: "misconfigured" | "policy_unavailable";
policy: "github-cloud-exact-origin" | "credential-fetch-no-redirect";
policy:
| "github-cloud-exact-origin"
| "github-app-id-canonical"
| "github-app-installation-id-canonical"
| "credential-fetch-no-redirect";
};

type ExchangeBodyFailure = {
Expand Down Expand Up @@ -78,18 +83,26 @@ export function isTrustedGithubApiBase(value: unknown): value is string {
}
}

function isCanonicalPositiveSafeInteger(value: string): boolean {
if (!positiveDecimalPattern.test(value)) return false;
const numericValue = Number(value);
return Number.isSafeInteger(numericValue) && String(numericValue) === value;
}

/**
* Accept only a compact, bounded JWT envelope before any decoding or credential use.
* Missing and non-Bearer authorization values are delegated to the normal API error path.
* Missing and non-Bearer authorization values are delegated to the normal API error path;
* a value using the Bearer scheme must itself be one exact compact JWT envelope.
* @param value Authorization header value observed at the request edge, or null when absent.
* @returns False only when a Bearer JWT envelope is structurally invalid or exceeds limits.
*/
export function isBoundedOidcBearer(value: string | null): boolean {
if (value === null) return true;
if (!/^Bearer(?:\s|$)/i.test(value)) return true;
if (value.length > MAX_AUTHORIZATION_HEADER_LENGTH) return false;

const match = value.match(/^Bearer\s+(\S+)$/i);
if (!match) return true;
if (value.length > MAX_AUTHORIZATION_HEADER_LENGTH) return false;
if (!match) return false;

const segments = match[1].split(".");
if (segments.length !== 3) return false;
Comment on lines 99 to 108

Copy link
Copy Markdown

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

isBoundedOidcBearer now treats any value starting with Bearer (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 400 ERR_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)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand Down Expand Up @@ -167,6 +180,23 @@ function hasDuplicateTargetRepositoryKey(body: Uint8Array): boolean {
return false;
}

function cancelRequestBodyBestEffort(request: Request, reason: string): void {
try {
if (request.body === null) return;
void request.body.cancel(reason).catch(() => undefined);
} catch {
// Cancellation is best-effort after the request has already been rejected.
}
}

function cancelReaderBestEffort(reader: ReadableStreamDefaultReader<Uint8Array>, reason: string): void {
try {
void reader.cancel(reason).catch(() => undefined);
} catch {
// Cancellation is best-effort after the request has already been rejected.
}
}

/**
* Consume and rebuild only JSON POST bodies within the exchange API's byte budget.
* Streaming consumption prevents a chunked request from bypassing Content-Length checks.
Expand All @@ -186,6 +216,7 @@ export async function boundExchangeJsonBody(request: Request): Promise<BoundedEx
.trim()
.toLowerCase();
if (mediaType !== "application/json") {
cancelRequestBodyBestEffort(request, "Noema exchange request body uses an unsupported media type");
return {
ok: false,
failure: { reason: "unsupported_media_type", status: 415 },
Expand All @@ -198,6 +229,7 @@ export async function boundExchangeJsonBody(request: Request): Promise<BoundedEx
&& /^\d+$/.test(declaredLength)
&& Number(declaredLength) > MAX_EXCHANGE_JSON_BODY_BYTES
) {
cancelRequestBodyBestEffort(request, "Noema exchange JSON body exceeds declared byte limit");
return {
ok: false,
failure: { reason: "too_large", status: 413 },
Expand All @@ -213,11 +245,7 @@ export async function boundExchangeJsonBody(request: Request): Promise<BoundedEx
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_EXCHANGE_JSON_BODY_BYTES) {
try {
await reader.cancel("Noema exchange JSON body exceeds byte limit");
} catch {
// Cancellation is best-effort after the request has already been rejected.
}
cancelReaderBestEffort(reader, "Noema exchange JSON body exceeds byte limit");
return {
ok: false,
failure: { reason: "too_large", status: 413 },
Expand All @@ -226,6 +254,7 @@ export async function boundExchangeJsonBody(request: Request): Promise<BoundedEx
chunks.push(value);
}
} catch {
cancelReaderBestEffort(reader, "Noema exchange JSON body could not be read");
return {
ok: false,
failure: { reason: "unreadable", status: 400 },
Expand Down Expand Up @@ -499,6 +528,27 @@ export default {
}
request = boundedRequest.request;

const appIdFailure: EgressFailure = {
hint: "Configure GITHUB_APP_ID as a canonical positive decimal safe integer.",
outcome: "misconfigured",
policy: "github-app-id-canonical",
};
if (!isCanonicalPositiveSafeInteger(env.GITHUB_APP_ID)) {
recordConfigurationFailure(request, appIdFailure);
return githubApiConfigurationResponse(request, appIdFailure);
}

const installationId = env.GITHUB_APP_INSTALLATION_ID;
if (installationId !== undefined && !isCanonicalPositiveSafeInteger(installationId)) {
const installationIdFailure: EgressFailure = {
hint: "Configure GITHUB_APP_INSTALLATION_ID as a canonical positive decimal safe integer when set.",
outcome: "misconfigured",
policy: "github-app-installation-id-canonical",
};
recordConfigurationFailure(request, installationIdFailure);
return githubApiConfigurationResponse(request, installationIdFailure);
}

const originFailure: EgressFailure = {
hint: "Configure GITHUB_API_BASE as the exact GitHub Cloud REST API origin.",
outcome: "misconfigured",
Expand Down
115 changes: 105 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Hardcoded owner/repository IDs become mandatory 403 gates

expectedRepositoryOwnerId and expectedRepositoryIds (index.ts) hardcode GitHub numeric IDs with no configuration. Real OIDC tokens always carry repository_owner_id, so the owner-id check (index.ts) applies to every request; a wrong constant fails all exchanges with 403. Tests reuse the same literals and cannot catch an incorrect value. Verify the IDs match the live org and repositories.

Open in Devin Review

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), {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
}

Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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,
};
}

Expand Down
Loading
Loading