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
7 changes: 7 additions & 0 deletions .changeset/allowfork-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"gcb-host": patch
---

Check `allowFork` through authenticated credentials instead of the unauthenticated public API: the
host uses its `gh` account, and the app provider uses the installation on the fork (rejecting a fork
without one). Both require a public fork and a public matched parent/source.
76 changes: 56 additions & 20 deletions packages/host/src/commands/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import { loadConfigSnapshot, resolveConfigPath } from '../config.ts'
import { clearInheritedTokens, setHostToken, tokenKeys } from '../gh-env.ts'
import { applyProxyChoice, fetcherFor, parseProxy, type ProxyChoice } from '../http.ts'
import { hostConfig, parseAllowEntry, repoAllowed } from '../policy.ts'
import { isForkOfRepository, isPublicRepository } from '../public-repo.ts'
import { isPublicRepository } from '../public-repo.ts'
import { ConfigWatcher } from '../reload.ts'

const run = promisify(execFile)
Expand Down Expand Up @@ -801,25 +801,26 @@ async function repoIsPublic(
/**
* Whether an allowlisted fork's parent is the requested repository.
*
* @param host - The forge host.
* @param allow - The configured allowlist entries.
* @param repository - The requested parent repository.
* @param proxy - The proxy choice for the check.
* @param apiBaseUrl - An optional API base.
* @param apiVersion - The API version header.
* @returns True when one allowlisted fork names the repository as its parent.
* The check runs through the host's gh account, so it uses its authenticated quota and the same
* proxy/account configuration as the operation it authorizes. The unauthenticated public API is
* rate-limited by shared egress addresses, which would silently turn allowFork off.
*/
async function forkAllows(
host: string,
allow: readonly string[],
repository: string,
proxy: ProxyChoice,
apiBaseUrl?: string,
apiVersion = '2022-11-28',
command: string,
extra: readonly string[],
account: string | undefined,
cwd: string,
): Promise<boolean> {
const base =
apiBaseUrl ??
(host.toLowerCase() === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`)
let env: NodeJS.ProcessEnv
try {
env = forkEnv(proxy, account, host, command, extra)
} catch {
return false
}
for (const raw of allow) {
const entry = parseAllowEntry(raw)
if (!entry || entry.owner === '*' || entry.repo === '*') {
Expand All @@ -830,7 +831,40 @@ async function forkAllows(
continue
}
try {
if (await isForkOfRepository(fetcherFor(proxy), base, fork, repository, apiVersion)) {
const result = await runBounded(command, [...extra, 'api', `repos/${fork}`], {
cwd,
env,
timeoutMs: 10_000,
})
if (result.code !== 0) {
continue
}
interface RepoMeta {
full_name?: unknown
private?: unknown
visibility?: unknown
}
const body = JSON.parse(result.stdout) as {
fork?: unknown
private?: unknown
visibility?: unknown
parent?: RepoMeta | null
source?: RepoMeta | null
} | null
const isPublic = (repo: RepoMeta | null | undefined): boolean =>
typeof repo === 'object' &&
repo !== null &&
repo.private === false &&
(typeof repo.visibility !== 'string' || repo.visibility === 'public')
if (body?.fork !== true || !isPublic(body)) {
continue
}
const parent = [body.parent, body.source].find(
(repo): repo is RepoMeta =>
typeof repo?.full_name === 'string' &&
repo.full_name.toLowerCase() === repository.toLowerCase(),
)
if (parent !== undefined && isPublic(parent)) {
return true
}
} catch {
Expand Down Expand Up @@ -1303,15 +1337,15 @@ async function openOne(
}
let allowedByFork = false
if (!repoAllowed(hostBlock.allow, request.repo) && hostBlock.allowFork === true) {
const apiBaseUrl = 'apiBaseUrl' in hostBlock ? hostBlock.apiBaseUrl : undefined
const apiVersion = 'apiVersion' in hostBlock ? hostBlock.apiVersion : undefined
allowedByFork = await forkAllows(
host,
hostBlock.allow,
request.repo,
request.proxy,
apiBaseUrl,
apiVersion,
command,
extra,
request.account,
repo,
)
}
if (!repoAllowed(hostBlock.allow, request.repo) && !allowedByFork) {
Expand Down Expand Up @@ -1474,8 +1508,10 @@ async function openOne(
block.allow,
target,
request.proxy,
'apiBaseUrl' in block ? block.apiBaseUrl : undefined,
'apiBaseUrl' in block ? block.apiVersion : undefined,
command,
extra,
request.account,
repo,
))
if (!allowedByFork) {
return { error: `repository ${target} is not in the allowlist for github.com` }
Expand Down
129 changes: 94 additions & 35 deletions packages/host/src/providers/github-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ export function createGithubAppProvider(options: GithubAppProviderOptions): Prov
method: string,
url: string,
body?: Record<string, unknown>,
signal?: AbortSignal,
authStage = false,
): Promise<{ json: unknown; link: string | null }> {
const jwt = signAppJwt({ privateKeyPem: keyPem, iss, nowMs: now() })
const headers: Record<string, string> = {
Expand All @@ -302,6 +304,7 @@ export function createGithubAppProvider(options: GithubAppProviderOptions): Prov
method,
headers,
...(body ? { body: JSON.stringify(body) } : {}),
...(signal === undefined ? {} : { signal }),
})

const text = await response.text()
Expand All @@ -312,6 +315,11 @@ export function createGithubAppProvider(options: GithubAppProviderOptions): Prov
json = null
}
if (!response.ok) {
if (authStage && (response.status === 403 || response.status === 429)) {
throw new ProviderConfigError(
`GitHub rate-limited or refused the app installation token request; retry later`,
)
}
const message =
json !== null && typeof json === 'object' && 'message' in json
? String((json as { message: unknown }).message)
Expand Down Expand Up @@ -718,7 +726,11 @@ export function createGithubAppProvider(options: GithubAppProviderOptions): Prov
* @param repo - The repository name.
* @returns The installation id, or undefined when the app is not installed there.
*/
async function repositoryInstallation(owner: string, repo: string): Promise<number | undefined> {
async function repositoryInstallation(
owner: string,
repo: string,
signal?: AbortSignal,
): Promise<number | undefined> {
const jwt = signAppJwt({ privateKeyPem: keyPem, iss, nowMs: now() })
const response = await fetchImpl(`${api}/repos/${owner}/${repo}/installation`, {
method: 'GET',
Expand All @@ -728,11 +740,17 @@ export function createGithubAppProvider(options: GithubAppProviderOptions): Prov
'x-github-api-version': apiVersion,
'user-agent': 'gcb',
},
...(signal === undefined ? {} : { signal }),
})
if (response.status === 404) {
return undefined
}
if (!response.ok) {
if (response.status === 403 || response.status === 429) {
throw new ProviderConfigError(
'GitHub rate-limited or refused the app installation lookup; retry later',
)
}
const text = await response.text()
throw new Error(
`GitHub API GET /repos/${owner}/${repo}/installation failed with ${response.status}: ${text.slice(0, 200)}`,
Expand Down Expand Up @@ -914,39 +932,82 @@ export function createGithubAppProvider(options: GithubAppProviderOptions): Prov
tokens.set(request.full, credential)
return { ...credential, cached: false }
},
/** Check a public fork with no credential; only confirmed fork relationships pass. */
/** Check a public fork through the app installation on that fork, not an anonymous request. */
async isForkOf(owner: string, repo: string, bases: readonly string[]): Promise<boolean> {
return withRepositoryResponse(
fetchImpl,
api,
`${owner}/${repo}`,
{ apiVersion, label: 'fork' },
async response => {
if (!response.ok) {
return false
}
const text = await response.text()
let body: {
fork?: unknown
parent?: { full_name?: unknown } | null
source?: { full_name?: unknown } | null
} | null
try {
body = JSON.parse(text) as typeof body
} catch {
return false
}
if (body?.fork !== true) {
return false
}
for (const candidate of [body.parent?.full_name, body.source?.full_name]) {
if (typeof candidate === 'string' && repoAllowed(bases, candidate)) {
return true
}
}
const signal = AbortSignal.timeout(10_000)
try {
const installationId = await repositoryInstallation(owner, repo, signal)
if (installationId === undefined) {
return false
},
)
}
const { json } = await call(
'POST',
`${api}/app/installations/${installationId}/access_tokens`,
{
repositories: [repo],
permissions: { metadata: 'read' },
},
signal,
true,
)
const token = (json as { token?: unknown } | null)?.token
if (typeof token !== 'string' || token === '') {
return false
}
const authenticated: FetchLike = (url, init) =>
fetchImpl(url, {
...init,
headers: { ...init.headers, authorization: `Bearer ${token}` },
})
return await withRepositoryResponse(
authenticated,
api,
`${owner}/${repo}`,
{ apiVersion, label: 'fork', authenticated: true, signal },
async response => {
if (!response.ok) {
return false
}
const text = await response.text()
let body: {
fork?: unknown
private?: unknown
visibility?: unknown
parent?: { full_name?: unknown; private?: unknown; visibility?: unknown } | null
source?: { full_name?: unknown; private?: unknown; visibility?: unknown } | null
} | null
try {
body = JSON.parse(text) as typeof body
} catch {
return false
}
const isPublic = (
value: { private?: unknown; visibility?: unknown } | null | undefined,
): boolean =>
typeof value === 'object' &&
value !== null &&
value.private === false &&
(typeof value.visibility !== 'string' || value.visibility === 'public')
if (body?.fork !== true || !isPublic(body)) {
return false
}
return [body.parent, body.source].some(
candidate =>
isPublic(candidate) &&
typeof candidate?.full_name === 'string' &&
repoAllowed(bases, candidate.full_name),
)
},
)
} catch (error) {
if (signal.aborted) {
throw new ProviderConfigError('GitHub timed out during the fork check; retry later')
}
if (error instanceof ProviderConfigError) {
throw error
}
return false
}
},

/** Verify a public repository with no credential; a 403/429 is a provider error. */
Expand Down Expand Up @@ -1019,9 +1080,7 @@ export function createGithubAppProvider(options: GithubAppProviderOptions): Prov
if (request.action === 'fork') {
const token = await personalToken()
if (token === undefined) {
throw new ProviderConfigError(
'no user token for fork; run gcb authorize',
)
throw new ProviderConfigError('no user token for fork; run gcb authorize')
}
const { text } = await callWithToken(
'POST',
Expand Down
18 changes: 13 additions & 5 deletions packages/host/src/public-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ export interface RepositoryCheckOptions {
readonly timeoutMs?: number
/** Name used in the timeout and rate-limit messages. */
readonly label?: string
/** Whether the request carried an app token; changes only the refusal wording. */
readonly authenticated?: boolean
/** Caller-owned deadline shared with the stages around this request. */
readonly signal?: AbortSignal
}

/**
* Run one unauthenticated repository request with a deadline and no redirects.
* Run one repository request with a deadline and no redirects.
*
* @param fetchImpl - The transport for this host's proxy choice.
* @param apiBaseUrl - The API base, without a trailing path.
Expand All @@ -33,6 +37,10 @@ export async function withRepositoryResponse<T>(
): Promise<T> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? CHECK_TIMEOUT_MS)
const signal =
options.signal === undefined
? controller.signal
: AbortSignal.any([controller.signal, options.signal])
try {
const response = await fetchImpl(`${apiBaseUrl}/repos/${repository}`, {
method: 'GET',
Expand All @@ -41,7 +49,7 @@ export async function withRepositoryResponse<T>(
'x-github-api-version': options.apiVersion ?? '2022-11-28',
'user-agent': 'gcb',
},
signal: controller.signal,
signal,
redirect: 'error',
})
if (response.status === 403 || response.status === 429) {
Expand All @@ -52,13 +60,13 @@ export async function withRepositoryResponse<T>(
const label = options.label ?? 'repository'
throw new ProviderConfigError(
exhausted
? `GitHub rate-limited the unauthenticated ${label} check; retry later or allowlist the repository`
: `GitHub refused the unauthenticated ${label} check; check the access policy or allowlist the repository`,
? `GitHub rate-limited the ${options.authenticated === true ? 'authenticated' : 'unauthenticated'} ${label} check; retry later or allowlist the repository`
: `GitHub refused the ${options.authenticated === true ? 'authenticated' : 'unauthenticated'} ${label} check; check the access policy or allowlist the repository`,
)
}
return await handle(response)
} catch (error) {
if (controller.signal.aborted) {
if (signal.aborted) {
throw new ProviderConfigError(
`GitHub timed out during the ${options.label ?? 'repository'} check; retry later`,
)
Expand Down
Loading
Loading