Skip to content

Commit 2ab7563

Browse files
iQQBotona-agent
andcommitted
feat: implement CSRF protection for OAuth flows with nonce validation
- Add NonceService for cryptographically secure nonce generation and validation - Include nonce in JWT state for OAuth authorization requests - Store nonce in secure httpOnly cookie with SameSite=strict - Validate nonce matches between state and cookie in auth callback - Add origin/referer header validation for additional CSRF protection - Use timing-safe comparison to prevent timing attacks - Clear nonce cookie after successful validation or on error This prevents CSRF attacks where malicious sites could initiate OAuth flows on behalf of users by ensuring authorization requests originate from Gitpod. Co-authored-by: Ona <[email protected]>
1 parent 96a7921 commit 2ab7563

File tree

5 files changed

+272
-1
lines changed

5 files changed

+272
-1
lines changed

components/server/src/auth/auth-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export interface AuthFlow {
9797
readonly host: string;
9898
readonly returnTo: string;
9999
readonly overrideScopes?: boolean;
100+
readonly nonce?: string;
100101
}
101102
export namespace AuthFlow {
102103
export function is(obj: any): obj is AuthFlow {

components/server/src/auth/authenticator.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { UserService } from "../user/user-service";
1818
import { AuthFlow, AuthProvider } from "./auth-provider";
1919
import { HostContextProvider } from "./host-context-provider";
2020
import { SignInJWT } from "./jwt";
21+
import { NonceService } from "./nonce-service";
2122

2223
@injectable()
2324
export class Authenticator {
@@ -30,6 +31,7 @@ export class Authenticator {
3031
@inject(TokenProvider) protected readonly tokenProvider: TokenProvider;
3132
@inject(UserAuthentication) protected readonly userAuthentication: UserAuthentication;
3233
@inject(SignInJWT) protected readonly signInJWT: SignInJWT;
34+
@inject(NonceService) protected readonly nonceService: NonceService;
3335

3436
@postConstruct()
3537
protected setup() {
@@ -77,6 +79,35 @@ export class Authenticator {
7779
if (!host) {
7880
throw new Error("Auth flow state is missing 'host' attribute.");
7981
}
82+
83+
// Validate nonce for CSRF protection
84+
const stateNonce = flowState.nonce;
85+
const cookieNonce = this.nonceService.getNonceFromCookie(req);
86+
87+
if (!this.nonceService.validateNonce(stateNonce, cookieNonce)) {
88+
log.error(`CSRF protection: Nonce validation failed`, {
89+
url: req.url,
90+
hasStateNonce: !!stateNonce,
91+
hasCookieNonce: !!cookieNonce,
92+
});
93+
res.status(403).send("CSRF protection: Invalid or missing nonce");
94+
return;
95+
}
96+
97+
// Validate origin for additional CSRF protection
98+
if (!this.nonceService.validateOrigin(req)) {
99+
log.error(`CSRF protection: Origin validation failed`, {
100+
url: req.url,
101+
origin: req.get("Origin"),
102+
referer: req.get("Referer"),
103+
});
104+
res.status(403).send("CSRF protection: Invalid request origin");
105+
return;
106+
}
107+
108+
// Clear the nonce cookie after successful validation
109+
this.nonceService.clearNonceCookie(res);
110+
80111
const hostContext = this.hostContextProvider.get(host);
81112
if (!hostContext) {
82113
throw new Error("No host context found.");
@@ -89,6 +120,8 @@ export class Authenticator {
89120
await hostContext.authProvider.callback(req, res, next);
90121
} catch (error) {
91122
log.error(`Failed to handle callback.`, error, { url: req.url });
123+
// Clear nonce cookie on error as well
124+
this.nonceService.clearNonceCookie(res);
92125
}
93126
} else {
94127
// Otherwise proceed with other handlers
@@ -170,9 +203,16 @@ export class Authenticator {
170203
return;
171204
}
172205

206+
// Generate nonce for CSRF protection
207+
const nonce = this.nonceService.generateNonce();
208+
209+
// Set nonce cookie
210+
this.nonceService.setNonceCookie(res, nonce);
211+
173212
const state = await this.signInJWT.sign({
174213
host,
175214
returnTo,
215+
nonce,
176216
});
177217

178218
// authenticate user
@@ -297,7 +337,14 @@ export class Authenticator {
297337
}
298338
// authorize Gitpod
299339
log.info(`(doAuthorize) wanted scopes (${override ? "overriding" : "merging"}): ${wantedScopes.join(",")}`);
300-
const state = await this.signInJWT.sign({ host, returnTo, overrideScopes: override });
340+
341+
// Generate nonce for CSRF protection
342+
const nonce = this.nonceService.generateNonce();
343+
344+
// Set nonce cookie
345+
this.nonceService.setNonceCookie(res, nonce);
346+
347+
const state = await this.signInJWT.sign({ host, returnTo, overrideScopes: override, nonce });
301348
authProvider.authorize(req, res, next, this.deriveAuthState(state), wantedScopes);
302349
}
303350
private mergeScopes(a: string[], b: string[]) {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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 { Container } from "inversify";
9+
import { Config } from "../config";
10+
import { NonceService } from "./nonce-service";
11+
12+
describe("NonceService", () => {
13+
let container: Container;
14+
let nonceService: NonceService;
15+
16+
beforeEach(() => {
17+
container = new Container();
18+
container.bind(Config).toConstantValue({
19+
hostUrl: {
20+
url: new URL("https://gitpod.io"),
21+
},
22+
auth: {
23+
session: {
24+
cookie: {
25+
secure: true,
26+
},
27+
},
28+
},
29+
} as any);
30+
container.bind(NonceService).toSelf();
31+
nonceService = container.get(NonceService);
32+
});
33+
34+
describe("generateNonce", () => {
35+
it("should generate unique nonces", () => {
36+
const nonce1 = nonceService.generateNonce();
37+
const nonce2 = nonceService.generateNonce();
38+
39+
expect(nonce1).to.be.a("string");
40+
expect(nonce2).to.be.a("string");
41+
expect(nonce1).to.not.equal(nonce2);
42+
expect(nonce1.length).to.be.greaterThan(40); // base64url encoded 32 bytes
43+
});
44+
});
45+
46+
describe("validateNonce", () => {
47+
it("should validate matching nonces", () => {
48+
const nonce = nonceService.generateNonce();
49+
const isValid = nonceService.validateNonce(nonce, nonce);
50+
expect(isValid).to.be.true;
51+
});
52+
53+
it("should reject different nonces", () => {
54+
const nonce1 = nonceService.generateNonce();
55+
const nonce2 = nonceService.generateNonce();
56+
const isValid = nonceService.validateNonce(nonce1, nonce2);
57+
expect(isValid).to.be.false;
58+
});
59+
60+
it("should reject undefined nonces", () => {
61+
const nonce = nonceService.generateNonce();
62+
expect(nonceService.validateNonce(nonce, undefined)).to.be.false;
63+
expect(nonceService.validateNonce(undefined, nonce)).to.be.false;
64+
expect(nonceService.validateNonce(undefined, undefined)).to.be.false;
65+
});
66+
});
67+
68+
describe("validateOrigin", () => {
69+
it("should accept requests from same origin", () => {
70+
const req = {
71+
get: (header: string) => {
72+
if (header === "Origin") return "https://gitpod.io";
73+
return undefined;
74+
},
75+
} as any;
76+
77+
const isValid = nonceService.validateOrigin(req);
78+
expect(isValid).to.be.true;
79+
});
80+
81+
it("should reject requests from different origin", () => {
82+
const req = {
83+
get: (header: string) => {
84+
if (header === "Origin") return "https://evil.com";
85+
return undefined;
86+
},
87+
} as any;
88+
89+
const isValid = nonceService.validateOrigin(req);
90+
expect(isValid).to.be.false;
91+
});
92+
93+
it("should reject requests without origin or referer", () => {
94+
const req = {
95+
get: () => undefined,
96+
} as any;
97+
98+
const isValid = nonceService.validateOrigin(req);
99+
expect(isValid).to.be.false;
100+
});
101+
102+
it("should accept requests with valid referer", () => {
103+
const req = {
104+
get: (header: string) => {
105+
if (header === "Referer") return "https://gitpod.io/login";
106+
return undefined;
107+
},
108+
} as any;
109+
110+
const isValid = nonceService.validateOrigin(req);
111+
expect(isValid).to.be.true;
112+
});
113+
});
114+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 { injectable, inject } from "inversify";
8+
import express from "express";
9+
import * as crypto from "crypto";
10+
import { Config } from "../config";
11+
12+
const NONCE_COOKIE_NAME = "__Host-_gitpod_oauth_nonce_";
13+
const NONCE_LENGTH = 32; // 32 bytes = 256 bits
14+
15+
@injectable()
16+
export class NonceService {
17+
@inject(Config) protected readonly config: Config;
18+
19+
/**
20+
* Generates a cryptographically secure random nonce
21+
*/
22+
generateNonce(): string {
23+
return crypto.randomBytes(NONCE_LENGTH).toString("base64url");
24+
}
25+
26+
/**
27+
* Sets the nonce cookie in the response
28+
*/
29+
setNonceCookie(res: express.Response, nonce: string): void {
30+
res.cookie(NONCE_COOKIE_NAME, nonce, {
31+
httpOnly: true,
32+
secure: this.config.auth.session.cookie.secure,
33+
sameSite: "strict", // Strict for CSRF protection
34+
maxAge: 5 * 60 * 1000, // 5 minutes (same as JWT state expiry)
35+
path: "/auth", // Limit to auth paths only
36+
});
37+
}
38+
39+
/**
40+
* Gets the nonce from the request cookies
41+
*/
42+
getNonceFromCookie(req: express.Request): string | undefined {
43+
return req.cookies?.[NONCE_COOKIE_NAME];
44+
}
45+
46+
/**
47+
* Clears the nonce cookie
48+
*/
49+
clearNonceCookie(res: express.Response): void {
50+
res.clearCookie(NONCE_COOKIE_NAME, {
51+
httpOnly: true,
52+
secure: this.config.auth.session.cookie.secure,
53+
sameSite: "strict",
54+
path: "/auth",
55+
});
56+
}
57+
58+
/**
59+
* Validates that the nonce from the state matches the nonce from the cookie
60+
*/
61+
validateNonce(stateNonce: string | undefined, cookieNonce: string | undefined): boolean {
62+
if (!stateNonce || !cookieNonce) {
63+
return false;
64+
}
65+
66+
// Use crypto.timingSafeEqual to prevent timing attacks
67+
if (stateNonce.length !== cookieNonce.length) {
68+
return false;
69+
}
70+
71+
const stateBuffer = Buffer.from(stateNonce, "base64url");
72+
const cookieBuffer = Buffer.from(cookieNonce, "base64url");
73+
74+
if (stateBuffer.length !== cookieBuffer.length) {
75+
return false;
76+
}
77+
78+
return crypto.timingSafeEqual(stateBuffer, cookieBuffer);
79+
}
80+
81+
/**
82+
* Validates that the request origin is from a trusted domain
83+
*/
84+
validateOrigin(req: express.Request): boolean {
85+
const origin = req.get("Origin");
86+
const referer = req.get("Referer");
87+
88+
// For OAuth callbacks, we expect either Origin or Referer header
89+
const requestSource = origin || referer;
90+
91+
if (!requestSource) {
92+
// No origin/referer header - this could be a direct navigation or CSRF attack
93+
return false;
94+
}
95+
96+
try {
97+
const sourceUrl = new URL(requestSource);
98+
const configUrl = new URL(this.config.hostUrl.url.toString());
99+
100+
// Validate that the request comes from the same origin as our configured host
101+
return sourceUrl.origin === configUrl.origin;
102+
} catch (error) {
103+
// Invalid URL format
104+
return false;
105+
}
106+
}
107+
}

components/server/src/container-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { HostContainerMapping } from "./auth/host-container-mapping";
4545
import { HostContextProvider, HostContextProviderFactory } from "./auth/host-context-provider";
4646
import { HostContextProviderImpl } from "./auth/host-context-provider-impl";
4747
import { AuthJWT, SignInJWT } from "./auth/jwt";
48+
import { NonceService } from "./auth/nonce-service";
4849
import { LoginCompletionHandler } from "./auth/login-completion-handler";
4950
import { VerificationService } from "./auth/verification-service";
5051
import { InstallationService } from "./auth/installation-service";
@@ -366,6 +367,7 @@ export const productionContainerModule = new ContainerModule(
366367

367368
bind(AuthJWT).toSelf().inSingletonScope();
368369
bind(SignInJWT).toSelf().inSingletonScope();
370+
bind(NonceService).toSelf().inSingletonScope();
369371

370372
bind(PrebuildManager).toSelf().inSingletonScope();
371373
bind(LazyPrebuildManager).toFactory((ctx) => {

0 commit comments

Comments
 (0)