Skip to content

Commit 9864027

Browse files
iQQBotona-agent
andcommitted
refactor: consolidate fragment protection and fix context provider conflict
Co-authored-by: Ona <[email protected]>
1 parent 2ab7563 commit 9864027

File tree

4 files changed

+99
-2
lines changed

4 files changed

+99
-2
lines changed

components/server/src/auth/authenticator.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { AuthFlow, AuthProvider } from "./auth-provider";
1919
import { HostContextProvider } from "./host-context-provider";
2020
import { SignInJWT } from "./jwt";
2121
import { NonceService } from "./nonce-service";
22+
import { ensureUrlHasFragment } from "./fragment-utils";
2223

2324
@injectable()
2425
export class Authenticator {
@@ -171,6 +172,9 @@ export class Authenticator {
171172
// returnTo defaults to workspaces url
172173
const workspaceUrl = this.config.hostUrl.asDashboard().toString();
173174
returnTo = returnTo || workspaceUrl;
175+
176+
// Ensure returnTo URL has a fragment to prevent OAuth token inheritance attacks
177+
returnTo = ensureUrlHasFragment(returnTo);
174178
const host: string = req.query.host?.toString() || "";
175179
const authProvider = host && (await this.getAuthProviderForHost(host));
176180
if (!host || !authProvider) {
@@ -268,17 +272,21 @@ export class Authenticator {
268272
);
269273
return;
270274
}
271-
const returnTo: string | undefined = req.query.returnTo?.toString();
275+
const returnToParam: string | undefined = req.query.returnTo?.toString();
272276
const host: string | undefined = req.query.host?.toString();
273277
const scopes: string = req.query.scopes?.toString() || "";
274278
const override = req.query.override === "true";
275279
const authProvider = host && (await this.getAuthProviderForHost(host));
276-
if (!returnTo || !host || !authProvider) {
280+
281+
if (!returnToParam || !host || !authProvider) {
277282
log.info(`Bad request: missing parameters.`, { "authorize-flow": true });
278283
res.redirect(this.getSorryUrl(`Bad request: missing parameters.`));
279284
return;
280285
}
281286

287+
// Ensure returnTo URL has a fragment to prevent OAuth token inheritance attacks
288+
const returnTo = ensureUrlHasFragment(returnToParam);
289+
282290
// For non-verified org auth provider, ensure user is an owner of the org
283291
if (!authProvider.info.verified && authProvider.info.organizationId) {
284292
const member = await this.teamDb.findTeamMembership(user.id, authProvider.info.organizationId);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { expect } from "chai";
8+
import { ensureUrlHasFragment } from "./fragment-utils";
9+
10+
describe("Fragment Protection", () => {
11+
describe("ensureUrlHasFragment", () => {
12+
it("should add empty fragment to URL without fragment", () => {
13+
const url = "https://gitpod.io/workspaces";
14+
const result = ensureUrlHasFragment(url);
15+
16+
expect(result).to.equal("https://gitpod.io/workspaces#");
17+
});
18+
19+
it("should preserve existing fragment", () => {
20+
const url = "https://gitpod.io/workspaces#existing";
21+
const result = ensureUrlHasFragment(url);
22+
23+
expect(result).to.equal(url);
24+
});
25+
26+
it("should handle URLs with query parameters", () => {
27+
const url = "https://gitpod.io/workspaces?tab=recent";
28+
const result = ensureUrlHasFragment(url);
29+
30+
expect(result).to.equal("https://gitpod.io/workspaces?tab=recent#");
31+
});
32+
33+
it("should handle invalid URLs gracefully", () => {
34+
const url = "not-a-valid-url";
35+
const result = ensureUrlHasFragment(url);
36+
37+
expect(result).to.equal("not-a-valid-url#");
38+
});
39+
40+
it("should prevent OAuth token inheritance attack", () => {
41+
// Scenario: OAuth provider redirects with token in fragment
42+
// If returnTo URL has no fragment, browser inherits the token fragment
43+
const returnToWithoutFragment = "https://gitpod.io/workspaces";
44+
const protectedUrl = ensureUrlHasFragment(returnToWithoutFragment);
45+
46+
// Now when OAuth provider redirects to: protectedUrl + token fragment
47+
// The existing fragment prevents inheritance
48+
expect(protectedUrl).to.include("#");
49+
expect(protectedUrl).to.not.equal(returnToWithoutFragment);
50+
});
51+
});
52+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
/**
8+
* Ensures a returnTo URL has a fragment to prevent OAuth token inheritance attacks.
9+
*
10+
* When OAuth providers use response_type=token, they redirect with access tokens
11+
* in URL fragments. If the returnTo URL doesn't have a fragment, browsers inherit
12+
* the current page's fragment, potentially exposing tokens to malicious sites.
13+
*
14+
* Uses an empty fragment (#) to prevent inheritance without interfering with
15+
* Gitpod's context provider resolution.
16+
*/
17+
export function ensureUrlHasFragment(url: string): string {
18+
try {
19+
const parsedUrl = new URL(url);
20+
// If URL already has a fragment, return as-is
21+
if (parsedUrl.hash) {
22+
return url;
23+
}
24+
// Add empty fragment to prevent inheritance
25+
// Using just "#" to avoid interfering with context provider resolution that
26+
// treats fragments as git URLs
27+
return url + "#";
28+
} catch (error) {
29+
// If URL is invalid, add fragment anyway
30+
return url + "#";
31+
}
32+
}

components/server/src/auth/login-completion-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { trackLogin } from "../analytics";
1818
import { SessionHandler } from "../session-handler";
1919
import { AuthJWT } from "./jwt";
2020
import { OneTimeSecretServer } from "../one-time-secret-server";
21+
import { ensureUrlHasFragment } from "./fragment-utils";
2122

2223
/**
2324
* The login completion handler pulls the strings between the OAuth2 flow, the ToS flow, and the session management.
@@ -58,6 +59,10 @@ export class LoginCompletionHandler {
5859

5960
// Update session info
6061
let returnTo = returnToUrl || this.config.hostUrl.asDashboard().toString();
62+
63+
// Ensure returnTo URL has a fragment to prevent OAuth token inheritance attacks
64+
returnTo = ensureUrlHasFragment(returnTo);
65+
6166
if (elevateScopes) {
6267
const elevateScopesUrl = this.config.hostUrl
6368
.withApi({

0 commit comments

Comments
 (0)