fix(vcs): link GitHub accounts that already have the app installed - #13880
HarshMN2345 wants to merge 19 commits into
Conversation
The signed state proves which project started the flow, not which installation it ended on. installation_id is a plain query parameter, so a caller holding a valid state for their own project could name any other account's installation of the app and attach its repositories to that project. The callback now requires the OAuth code GitHub returns during installation, and links an installation only when GET /user/installations shows the GitHub user behind that code can access it.
When the app is already installed on the account a user picks, GitHub sends them to that installation's settings, and saving there redirects with setup_action=update and no state. The callback stopped on the missing state, so an account with the app installed could never be connected to another project. Authorize now mirrors the signed state into a ten-minute HttpOnly cookie scoped to the callback path on the console host, since Authorize may be served from a regional host while GitHub returns to the console one. The callback falls back to it when state is missing, still verifies its signature, and clears it after one use. The installation access check makes it safe to decide the project from the cookie.
|
✨ Benchmark resultsComparing
Per-scenario breakdown & investigation detailsMetrics below reflect the current branch (after). Δ P95 compares against the base.
Top API waits (after)
|
The cookie is sent on any top-level navigation to the callback, so a page on another site could pair a signed-in user's pending state with its own code and installation_id and attach that installation to their project. The access check cannot catch this, because the attacker's code does grant access to the attacker's installation. From the cookie, the callback now only relinks an installation this user can already see linked to another project, which is the case GitHub drops state for. It falls back to the cookie only on setup_action=update and clears it only when it was used, so a callback carrying its own state no longer consumes another flow's cookie. The access refusal mentions SAML single sign-on, and the cookie constants are named for GitHub, the only provider that uses them.
…tion' into fix/vcs-github-existing-installation
The cookie path read installations with the caller's permissions. Members scoped to one project cannot read installations linked to its sibling projects, so they were refused even though the installation already belongs to their organization. The check now accepts an installation already linked to a project the user can read, or to any project in the target project's organization. The organization lookup skips authorization, since it only compares organizations, and loads just projectId from the installations. The refusal no longer tells users to uninstall the app, which would break the projects still using it, and a stateless approval request reports the owner-approval message again.
Authorize scoped the cookie to the console host, so a browser dropped it when the API was served from a sibling host such as api.example.com. The callback can only read the project through the console session, so the cookie now gets the same reach: the registrable domain with root sessions, else the console host and its subdomains. A second connection started in the same browser overwrote the first flow's cookie, so an update finishing the first flow could land in the second project. Authorize now clears a pending cookie that belongs to another project instead of replacing it, and the callback clears the cookie only when it belongs to the project whose flow just ended. The cookie path widens to /v1/vcs/github so Authorize can see it. The stateless error no longer claims the installation was completed on GitHub, which a restarted or overlapping flow also hits.
The GitHub callback does not extend the shared Callback/Base, so the Gitea suite never ran it, and every VCSGitHubBase suite skips without a GitHub App. This suite drives the real route with signed state and the state cookie: missing state, an owner-approval request, an installation without a code, cookie recovery and clearing, another project's pending cookie, a tampered cookie, a cookie on a non-update callback, and an unlinked installation from the cookie.
…tion' into fix/vcs-github-existing-installation
Clearing the state cookie when another project's connection was pending made the connection the user started last fail whenever GitHub returned without state. GitHub returns nothing the callback could tell two flows apart by, so Authorize now lets the later connection replace the pending one. The callback still clears only its own project's cookie, so a flow that carries its own state leaves another project's cookie alone. With Authorize no longer reading the cookie, its path narrows back to the callback.
The suite rebuilt the state payload and its signature itself, so a change to how Authorize encodes state would have meant changing the tests too. It now obtains state and the cookie from the real Authorize route. The VCSGitHub CI job sets an app name for that, which Authorize only uses to build GitHub's URL; nothing contacts GitHub and GitHub stays unconfigured.
…roject With one state cookie per browser, a second connection either replaced the first flow's state, so an update for the first project could land in the second, or cleared it, failing the connection started last. GitHub returns nothing the callback could tell the flows apart by. Authorize now keeps one cookie per project. A stateless update uses the pending cookie when there is exactly one; with several it refuses with an explicit message and clears them, so the next attempt finds a single one. A finished flow still clears only its own project's cookie.
The suite reads each project's state cookie from Authorize by name, and a stateless update carrying cookies for two projects must be refused instead of linked to either.
…copy The suite picked the state cookie by its name prefix and asserted error message wording, so renaming the cookie or rewording a message would have broken it while the flow still worked. It now takes whatever cookie Authorize sets and asserts status codes only; the approval-request case, which only checked wording, is dropped.
| 'installation_id' => '1234567', | ||
| ], $this->authorizeHelper($this->getProject()['$id'])['cookie'], $this->authorizeHelper($this->getProject(true)['$id'])['cookie']); | ||
|
|
||
| $this->assertEquals(400, $response['headers']['status-code']); |
There was a problem hiding this comment.
Ambiguous callback test misses clearing When two projects have pending connections, this test checks only for HTTP 400—the same result as a callback with no state. It would pass if the callback stopped clearing the pending cookies, leaving the next connection attempt blocked. Please test the observable cookie-clearing and retry behavior.
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/e2e/Services/VCSGitHub/VCSGitHubCallbackConsoleClientTest.php
Line: 145
Comment:
**Ambiguous callback test misses clearing** When two projects have pending connections, this test checks only for HTTP 400—the same result as a callback with no state. It would pass if the callback stopped clearing the pending cookies, leaving the next connection attempt blocked. Please test the observable cookie-clearing and retry behavior.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Refusing every stateless update while several connections were pending meant an abandoned flow, still within its ten minutes, blocked a valid one. The installation already says which projects could take it: those in an organization that uses it, or any when this user can read a project that does. The callback now keeps the pending connections that qualify and uses a lone one, refusing and clearing only when several still qualify. The relink check reuses the same lookups.
VCSGitHubConsoleClientTest skipped every test when no GitHub App was configured, so the callback cases lived in a class of their own. The gate now sits where the suite actually reaches GitHub, in setupInstallation() and testGitHubAuthorize(), so the cases that need no GitHub App run in the existing suite and the separate class goes away.
The test rewrote a field inside the decoded cookie, so a change to how state is encoded would have broken it while tampering was still refused. It now alters the cookie as issued and expects the callback to reject it.
| $response = $this->callGitHubCallbackHelper([ | ||
| 'setup_action' => 'update', | ||
| 'installation_id' => '1234567', | ||
| ], $this->authorizeHelper($this->getProject()['$id'])['cookie'] . 'x'); |
There was a problem hiding this comment.
Tampering test misses signature failures Appending
x makes the cookie value invalid JSON. The callback then has no project ID, so this test still gets HTTP 400 even if signature verification is removed. It no longer checks that a well-formed state with an altered signed field is rejected, allowing a signature-check regression to pass the suite.
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/e2e/Services/VCSGitHub/VCSGitHubConsoleClientTest.php
Line: 1139
Comment:
**Tampering test misses signature failures** Appending `x` makes the cookie value invalid JSON. The callback then has no project ID, so this test still gets HTTP 400 even if signature verification is removed. It no longer checks that a well-formed state with an altered signed field is rejected, allowing a signature-check regression to pass the suite.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
What does this PR do?
Connecting GitHub from a project's settings failed with
This installation was completed on GitHub, so it could not be connected to a projectwhenever the organization already had the Appwrite app installed, for example because it was already linked to another project.GitHub sends an already-installed account to the installation's settings page. Saving there redirects to the callback with
setup_action=updateand without thestatewe passed toinstallations/new, so the callback can't tell which project started the flow./v1/vcs/github/callback, one per project. The callback can only read the project through the console session, so the cookie gets the same reach as that session: the registrable domain when_APP_CONSOLE_ROOT_SESSIONis enabled (Cloud), else the console host and its subdomains (which covers Cloud's regional hosts either way).setup_action=updatewith nostate, GitHub returns nothing to tell pending connections apart by, so with several it keeps those the installation could be relinked into (see below) and uses a lone one; only when several still qualify does it refuse with an explicit message and clear them rather than guess. It still checks the signature, and a finished flow clears only its own project's cookie.projectId.codecan access it (GET /user/installations). Before this,installation_idwas trusted as sent next to any validstate.Limits
/user/installationsproves the GitHub user can access the installation (at least one of its repositories), not that they own or administer it. That's GitHub's documented check, and much stricter than before.api.example.comandconsole.example.com), the fallback needs_APP_CONSOLE_ROOT_SESSION, like the console session itself: without it the session never reaches the callback, so its project lookup fails with or without the cookie.?error=. Neither console shows that parameter yet: the Svelte console also reuses the success alert in its failure URL. That's a follow-up in the console repos.Self-hosted: linking now requires the GitHub App's Request user authorization (OAuth) during installation setting, which the self-hosting docs already require.
Test Plan
New credential-free suite
VCSGitHubCallbackConsoleClientTest(runs in the existing VCSGitHub CI job; theVCSGitHubBasesuites skip there without a GitHub App): missing state, owner-approval request, installation without a code, cookie recovery and clearing, another project's pending cookie kept, two pending projects neither of which fits refused, tampered cookie, cookie ignored on a non-update callback, unlinked installation refused from the cookie. Tests take state and the cookie from the realauthorizeroute; the VCSGitHub CI job sets_APP_VCS_GITHUB_APP_NAMEsoauthorizecan build GitHub's URL. Nothing contacts GitHub, and GitHub stays unconfigured.Also ran against an API container built from this branch, with a console session:
authorizereturns the 301 to GitHub plusSet-Cookie: a_github_state=…; Max-Age=600; path=/v1/vcs/github/callback; domain=.<console host>; HttpOnly; SameSite=Lax.setup_action=update, the cookie and nostate:Invalid state parameter.setup_action=install, the cookie and nostate→ the cookie is ignored and left alone (400, as before).setup_action=requestand nostate→ 400 with the owner-approval message.authorizewith_APP_CONSOLE_ROOT_SESSIONenabled and console hostconsole.example.com→Domain=.example.com.stateand the cookie, but nocode→ refused, cookie left alone.Not exercised: a successful link with a real GitHub OAuth
code. That needs a live GitHub App installation.Related PRs and Issues
None.
Checklist