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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
### Fixes

- Bound GitHub membership verification to 15 seconds, including stalled response bodies and team pagination, so authentication fails closed without leaving shared checks stuck indefinitely. [PR 2481](https://github.com/openclaw/crabbox/pull/2481).
- Bound GitHub OAuth code exchange and post-exchange verification while preserving one-use-code handling, the existing verification retry, and encrypted credential reuse on callback retries. [PR 2482](https://github.com/openclaw/crabbox/pull/2482).
- Delete each new Scaleway lease's allocation-recorded root disk on release, preserve recovery state after cleanup failures, and leave later-attached and legacy untracked disks untouched. [PR 2468](https://github.com/openclaw/crabbox/pull/2468).
- Preserve caller cancellation causes and timeout classification during Scaleway public-IP readiness without changing its five-minute budget or timeout exit code. [PR 2468](https://github.com/openclaw/crabbox/pull/2468).
- Preserve reclaimed fixed leases during cleanup by distinguishing ownership-fence rejection from an admitted deletion in the shared engine. [PR 2462](https://github.com/openclaw/crabbox/pull/2462).
Expand Down
5 changes: 5 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,11 @@ service account, or `DAYTONA_CRABBOX_KEY`. Node additionally requires

GitHub OAuth start routes remain unauthenticated so a new user can bootstrap login.
GitHub membership verification shares a 15-second deadline across account, organization, and team-page requests, including response bodies. A stalled check fails closed and releases its shared in-flight entry so later requests can retry; it never extends an expired success-cache entry.
OAuth uses the same deadline owner: code exchange gets its own 15-second budget,
and each post-exchange attempt shares 15 seconds across identity, verified-email,
and membership lookups. Code exchange is never automatically retried; a timeout
leaves its remote outcome unknown and may require a new login. Post-exchange
verification retains the existing single retry and encrypted credential reuse.
The coordinator limits active attempts to ten per caller source and 100 globally for
both CLI and portal login, after removing expired attempts. Node deployments behind a
reverse proxy must configure `CRABBOX_TRUSTED_PROXY_CIDRS`; otherwise caller limits use
Expand Down
95 changes: 17 additions & 78 deletions worker/src/github-membership.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import {
GitHubTransientError,
withGitHubRequestDeadline,
type GitHubRequestDeadline,
} from "./github-request";
import type { Env } from "./types";

const githubAPIURL = "https://api.github.com";
const defaultMembershipCacheSeconds = 5 * 60;
const maxMembershipCacheSeconds = 60 * 60;
const maxGitHubTeamPages = 10;
const membershipCacheMaxEntries = 1024;
const membershipVerificationTimeoutMS = 15_000;

interface GitHubTeam {
slug?: string;
Expand Down Expand Up @@ -52,63 +55,12 @@ export type GitHubMembershipEnv = Pick<
const membershipCache = new Map<string, number>();
const membershipLoads = new Map<string, Promise<void>>();

class GitHubMembershipDeadline {
private readonly controller = new AbortController();
private readonly timer: ReturnType<typeof setTimeout>;
private readonly expired: Promise<never>;

constructor() {
let expire!: (error: GitHubTransientError) => void;
this.expired = new Promise((_, reject) => {
expire = reject;
});
this.timer = setTimeout(() => {
const error = new GitHubTransientError("GitHub membership verification timed out.");
expire(error);
this.controller.abort(error);
}, membershipVerificationTimeoutMS);
}

async wait<T>(operation: () => Promise<T>): Promise<T> {
this.controller.signal.throwIfAborted();
return await Promise.race([operation(), this.expired]);
}

fetch(path: string, accessToken: string): Promise<Response> {
return this.wait(() =>
fetch(`${githubAPIURL}${path}`, {
headers: githubHeaders(accessToken),
signal: this.controller.signal,
}),
);
}

json<T>(response: Response): Promise<T> {
return this.wait(() => response.json() as Promise<T>);
}

close(): void {
clearTimeout(this.timer);
this.controller.abort();
}
}

async function withMembershipDeadline<T>(
operation: (deadline: GitHubMembershipDeadline) => Promise<T>,
): Promise<T> {
const deadline = new GitHubMembershipDeadline();
try {
return await deadline.wait(() => operation(deadline));
} finally {
deadline.close();
}
}

export async function requireGitHubLoginMembership(
accessToken: string,
identity: Pick<GitHubMembershipIdentity, "owner" | "login">,
requestedOrg: string,
env: GitHubMembershipEnv,
deadline: GitHubRequestDeadline,
): Promise<string> {
requireSafeGitHubRevocationConfig(env);
if (githubUserIsRevoked(identity, env)) {
Expand All @@ -121,9 +73,7 @@ export async function requireGitHubLoginMembership(
throw new GitHubAuthorizationError("GitHub login is not configured with an allowed org.");
}
const policy = githubMembershipPolicy({ ...identity, org }, env);
return withMembershipDeadline((deadline) =>
requireExactGitHubMembership(accessToken, identity.login, policy, deadline),
);
return requireExactGitHubMembership(accessToken, identity.login, policy, deadline);
}

export async function requireCurrentGitHubMembership(
Expand Down Expand Up @@ -162,7 +112,7 @@ export async function requireFreshGitHubMembership(
normalizedPolicy?: GitHubMembershipPolicy,
): Promise<void> {
const policy = normalizedPolicy ?? githubMembershipPolicy(identity, env);
await withMembershipDeadline(async (deadline) => {
await withGitHubRequestDeadline(async (deadline) => {
await requireExactGitHubAccount(identity.accessToken, identity.owner, identity.login, deadline);
await requireExactGitHubMembership(identity.accessToken, identity.login, policy, deadline);
});
Expand Down Expand Up @@ -229,15 +179,15 @@ async function requireExactGitHubAccount(
accessToken: string,
owner: string,
login: string,
deadline: GitHubMembershipDeadline,
deadline: GitHubRequestDeadline,
): Promise<void> {
const expectedID = githubAccountID(owner);
if (expectedID === undefined) {
throw new GitHubAuthorizationError(
"This GitHub session uses a legacy mutable identity. Log in again.",
);
}
const response = await deadline.fetch("/user", accessToken);
const response = await deadline.api("/user", accessToken);
if (!response.ok) {
throw await githubResponseError(
response,
Expand All @@ -262,10 +212,10 @@ async function requireExactGitHubMembership(
accessToken: string,
login: string,
policy: GitHubMembershipPolicy,
deadline: GitHubMembershipDeadline,
deadline: GitHubRequestDeadline,
): Promise<string> {
const exactOrg = policy.org;
const response = await deadline.fetch(
const response = await deadline.api(
`/user/memberships/orgs/${encodeURIComponent(exactOrg)}`,
accessToken,
);
Expand Down Expand Up @@ -293,7 +243,7 @@ async function requireAllowedTeamMembership(
accessToken: string,
login: string,
policy: GitHubMembershipPolicy,
deadline: GitHubMembershipDeadline,
deadline: GitHubRequestDeadline,
): Promise<void> {
if (policy.allowedTeams.length === 0) return;
const allowedKeys = new Set(policy.allowedTeams);
Expand Down Expand Up @@ -355,12 +305,12 @@ function invalidAllowedTeamConfig(): GitHubAuthorizationError {

async function userGitHubTeams(
accessToken: string,
deadline: GitHubMembershipDeadline,
deadline: GitHubRequestDeadline,
): Promise<GitHubTeam[]> {
const teams: GitHubTeam[] = [];
for (let page = 1; page <= maxGitHubTeamPages; page += 1) {
// oxlint-disable-next-line eslint/no-await-in-loop -- each page determines whether another exists.
const response = await deadline.fetch(`/user/teams?per_page=100&page=${page}`, accessToken);
const response = await deadline.api(`/user/teams?per_page=100&page=${page}`, accessToken);
if (!response.ok) {
// oxlint-disable-next-line eslint/no-await-in-loop -- classify the current page response before advancing.
throw await githubResponseError(
Expand Down Expand Up @@ -420,19 +370,10 @@ function teamKey(org: string, slug: string): string {
return `${org.toLowerCase()}/${slug.toLowerCase()}`;
}

function githubHeaders(accessToken: string): Record<string, string> {
return {
accept: "application/vnd.github+json",
authorization: `Bearer ${accessToken}`,
"user-agent": "crabbox-coordinator",
"x-github-api-version": "2022-11-28",
};
}

async function githubResponseError(
response: Response,
message: string,
deadline: GitHubMembershipDeadline,
deadline: GitHubRequestDeadline,
): Promise<Error> {
if (response.status === 429 || response.status >= 500) {
return new GitHubTransientError(message);
Expand All @@ -451,7 +392,7 @@ async function githubResponseError(

async function githubErrorDetails(
response: Response,
deadline: GitHubMembershipDeadline,
deadline: GitHubRequestDeadline,
): Promise<{ message: string; documentationURL: string }> {
try {
const body = await deadline.json<{ message?: unknown; documentation_url?: unknown }>(response);
Expand Down Expand Up @@ -493,5 +434,3 @@ function github403RequiresReauthentication(details: {
export class GitHubAuthorizationError extends Error {}

export class GitHubCredentialError extends GitHubAuthorizationError {}

export class GitHubTransientError extends Error {}
62 changes: 62 additions & 0 deletions worker/src/github-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const githubAPIURL = "https://api.github.com";
const verificationTimeoutMS = 15_000;

export class GitHubTransientError extends Error {}

export class GitHubRequestDeadline {
private readonly controller = new AbortController();
private readonly timer: ReturnType<typeof setTimeout>;
private readonly expired: Promise<never>;

constructor() {
let expire!: (error: GitHubTransientError) => void;
this.expired = new Promise((_, reject) => {
expire = reject;
});
this.timer = setTimeout(() => {
const error = new GitHubTransientError("GitHub verification timed out.");
expire(error);
this.controller.abort(error);
}, verificationTimeoutMS);
}

async wait<T>(operation: () => Promise<T>): Promise<T> {
this.controller.signal.throwIfAborted();
return await Promise.race([operation(), this.expired]);
}

fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return this.wait(() => fetch(input, { ...init, signal: this.controller.signal }));
}

api(path: string, accessToken: string): Promise<Response> {
return this.fetch(`${githubAPIURL}${path}`, {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${accessToken}`,
"user-agent": "crabbox-coordinator",
"x-github-api-version": "2022-11-28",
},
});
}

json<T>(response: Response): Promise<T> {
return this.wait(() => response.json() as Promise<T>);
}

close(): void {
clearTimeout(this.timer);
this.controller.abort();
}
}

export async function withGitHubRequestDeadline<T>(
operation: (deadline: GitHubRequestDeadline) => Promise<T>,
): Promise<T> {
const deadline = new GitHubRequestDeadline();
try {
return await deadline.wait(() => operation(deadline));
} finally {
deadline.close();
}
}
Loading
Loading