Skip to content

Commit 22dbfed

Browse files
authored
Avoiding CSRF
1 parent 46aa6ca commit 22dbfed

File tree

1 file changed

+200
-149
lines changed

1 file changed

+200
-149
lines changed

cloudflare/workers-github-oauth.md

Lines changed: 200 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,104 @@ This is the simplest possible design for an OAuth flow: send the user straight t
2828

2929
Here's [the full Claude transcript](https://gist.github.com/simonw/975b8934066417fe771561a1b672ad4f). Claude gave me almost *exactly* what I needed - the only missing detail was that it set the `redirectUri` to `url.origin` (just the site domain) when it should have been the full URL to the worker page.
3030

31-
I edited the code to its final version, which looked like this:
31+
I tweaked the code to fix this, and later again to add error handling and then to address a potential security issue. My currently deployed code looks like this:
32+
3233
```javascript
3334
export default {
3435
async fetch(request, env) {
35-
const url = new URL(request.url);
36-
const clientId = env.GITHUB_CLIENT_ID;
37-
const clientSecret = env.GITHUB_CLIENT_SECRET;
38-
const redirectUri = env.GITHUB_REDIRECT_URI;
39-
40-
// If we have a code, exchange it for an access token
41-
if (url.searchParams.has('code')) {
42-
const code = url.searchParams.get('code');
36+
const generateHTML = ({ title, content, isError = false, headers = {} }) => {
37+
return new Response(`
38+
<!DOCTYPE html>
39+
<html>
40+
<head>
41+
<title>${title}</title>
42+
<style>
43+
body {
44+
font-family: -apple-system, system-ui, sans-serif;
45+
padding: 2rem;
46+
max-width: 600px;
47+
margin: 0 auto;
48+
text-align: center;
49+
}
50+
.message {
51+
padding: 1rem;
52+
margin: 1rem 0;
53+
border-radius: 4px;
54+
background-color: ${isError ? '#ffebee' : '#e8f5e9'};
55+
border: 1px solid ${isError ? '#ffcdd2' : '#c8e6c9'};
56+
color: ${isError ? '#b71c1c' : '#2e7d32'};
57+
}
58+
</style>
59+
</head>
60+
<body>
61+
<div class="message">
62+
${content}
63+
</div>
64+
${isError ? '<p>Please close this window and try again. If the problem persists, contact support.</p>' : ''}
65+
</body>
66+
</html>
67+
`, {
68+
headers: {
69+
'Content-Type': 'text/html',
70+
...headers
71+
},
72+
status: isError ? 400 : 200
73+
});
74+
};
75+
76+
try {
77+
const url = new URL(request.url);
78+
const clientId = env.GITHUB_CLIENT_ID;
79+
const clientSecret = env.GITHUB_CLIENT_SECRET;
80+
const redirectUri = env.GITHUB_REDIRECT_URI;
4381

44-
// Exchange the code for an access token
82+
if (!url.searchParams.has('code')) {
83+
// Initial authorization request
84+
const state = crypto.randomUUID();
85+
const githubAuthUrl = new URL('https://github.com/login/oauth/authorize');
86+
githubAuthUrl.searchParams.set('client_id', clientId);
87+
githubAuthUrl.searchParams.set('redirect_uri', redirectUri);
88+
githubAuthUrl.searchParams.set('scope', 'gist');
89+
githubAuthUrl.searchParams.set('state', state);
90+
91+
// Create headers object with the state cookie
92+
const headers = new Headers({
93+
'Location': githubAuthUrl.toString(),
94+
'Set-Cookie': `github_auth_state=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`
95+
});
96+
97+
return new Response(null, {
98+
status: 302,
99+
headers
100+
});
101+
}
102+
103+
// Callback handling
104+
const returnedState = url.searchParams.get('state');
105+
const cookies = request.headers.get('Cookie') || '';
106+
const stateCookie = cookies.split(';')
107+
.map(cookie => cookie.trim())
108+
.find(cookie => cookie.startsWith('github_auth_state='));
109+
const savedState = stateCookie ? stateCookie.split('=')[1] : null;
110+
111+
// Cookie cleanup header
112+
const clearStateCookie = {
113+
'Set-Cookie': 'github_auth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'
114+
};
115+
116+
// Validate state parameter
117+
if (!savedState || savedState !== returnedState) {
118+
return generateHTML({
119+
title: 'Invalid State Parameter',
120+
content: `
121+
<h3>Security Error</h3>
122+
<p>Invalid state parameter detected. This could indicate a CSRF attempt.</p>
123+
`,
124+
isError: true,
125+
headers: clearStateCookie
126+
});
127+
}
128+
45129
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
46130
method: 'POST',
47131
headers: {
@@ -51,50 +135,66 @@ export default {
51135
body: JSON.stringify({
52136
client_id: clientId,
53137
client_secret: clientSecret,
54-
code: code,
55-
redirect_uri: redirectUri
138+
code: url.searchParams.get('code'),
139+
redirect_uri: redirectUri,
140+
state: returnedState
56141
})
57142
});
58143

59144
const tokenData = await tokenResponse.json();
60145

61-
// Return HTML that stores the token and closes the window
62-
return new Response(`
63-
<!DOCTYPE html>
64-
<html>
65-
<head>
66-
<title>GitHub OAuth Success</title>
67-
</head>
68-
<body>
69-
<script>
146+
if (tokenData.error) {
147+
return generateHTML({
148+
title: 'GitHub OAuth Error',
149+
content: `
150+
<h3>Authentication Error</h3>
151+
<p>Error: ${tokenData.error}</p>
152+
${tokenData.error_description ? `<p>Description: ${tokenData.error_description}</p>` : ''}
153+
`,
154+
isError: true,
155+
headers: clearStateCookie
156+
});
157+
}
158+
159+
// Success response with cookie cleanup
160+
return generateHTML({
161+
title: 'GitHub OAuth Success',
162+
content: `
163+
<h2>Authentication successful!</h2>
164+
<p>You can close this window.</p>
165+
<script>
166+
try {
70167
localStorage.setItem('github_token', '${tokenData.access_token}');
71-
document.body.innerHTML = 'Authentication successful! You can close this window.';
72-
</script>
73-
</body>
74-
</html>
75-
`, {
168+
} catch (err) {
169+
document.body.innerHTML += '<p style="color: #c62828;">Warning: Unable to store token in localStorage</p>';
170+
}
171+
</script>
172+
`,
173+
headers: clearStateCookie
174+
});
175+
176+
} catch (error) {
177+
// Error response with cookie cleanup
178+
return generateHTML({
179+
title: 'Unexpected Error',
180+
content: `
181+
<h3>Unexpected Error</h3>
182+
<p>An unexpected error occurred during authentication.</p>
183+
<p>Details: ${error.message}</p>
184+
`,
185+
isError: true,
76186
headers: {
77-
'Content-Type': 'text/html'
187+
'Set-Cookie': 'github_auth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'
78188
}
79189
});
80190
}
81-
82-
// If no code, redirect to GitHub OAuth
83-
const githubAuthUrl = new URL('https://github.com/login/oauth/authorize');
84-
githubAuthUrl.searchParams.set('client_id', clientId);
85-
githubAuthUrl.searchParams.set('redirect_uri', redirectUri);
86-
githubAuthUrl.searchParams.set('scope', 'gist');
87-
githubAuthUrl.searchParams.set('state', crypto.randomUUID());
88-
89-
return Response.redirect(githubAuthUrl.toString(), 302);
90191
}
91192
};
92193
```
93-
I find it hard to imagine a simpler implementation of this pattern.
94194

95195
The GitHub API has been around for a very long time, so it shouldn't be surprising that Claude knows exactly how to write the above code. I was still delighted at how much work it had saved me.
96196

97-
(I should mention now that I completed this entire project on my phone, before I got up to make the morning coffee.)
197+
(I should mention now that I completed the entire initial project on my phone, before I got up to make the morning coffee.)
98198

99199
## Deploying this to Cloudflare Workers
100200

@@ -166,7 +266,7 @@ Here's the page that uses that: https://tools.simonwillison.net/openai-audio-out
166266

167267
## Adding error handling
168268

169-
That code Claude wrote is missing an important detail: error handling. If the GitHub API returns an error - e.g. because the `?code=` is invalid - the page won't reflect that to the user.
269+
The initial code that Claude wrote was missing an important detail: error handling. If the GitHub API returns an error - e.g. because the `?code=` is invalid - the page won't reflect that to the user.
170270

171271
I pasted in the code and prompted:
172272

@@ -176,118 +276,69 @@ Claude [wrote more code](https://gist.github.com/simonw/85debbdf3d981ff7e54f8cdb
176276

177277
> `refactor that code to have less code for the HTML`
178278
179-
And [got back this](https://gist.github.com/simonw/85debbdf3d981ff7e54f8cdb6be47578#rewrite-untitled), which is much better. I've now deployed that as an update to the original script.
279+
And [got back this](https://gist.github.com/simonw/85debbdf3d981ff7e54f8cdb6be47578#rewrite-untitled), which is much better.
180280

181-
```javascript
182-
export default {
183-
async fetch(request, env) {
184-
const generateHTML = ({ title, content, isError = false }) => {
185-
return new Response(`
186-
<!DOCTYPE html>
187-
<html>
188-
<head>
189-
<title>${title}</title>
190-
<style>
191-
body {
192-
font-family: -apple-system, system-ui, sans-serif;
193-
padding: 2rem;
194-
max-width: 600px;
195-
margin: 0 auto;
196-
text-align: center;
197-
}
198-
.message {
199-
padding: 1rem;
200-
margin: 1rem 0;
201-
border-radius: 4px;
202-
background-color: ${isError ? '#ffebee' : '#e8f5e9'};
203-
border: 1px solid ${isError ? '#ffcdd2' : '#c8e6c9'};
204-
color: ${isError ? '#b71c1c' : '#2e7d32'};
205-
}
206-
</style>
207-
</head>
208-
<body>
209-
<div class="message">
210-
${content}
211-
</div>
212-
${isError ? '<p>Please close this window and try again. If the problem persists, contact support.</p>' : ''}
213-
</body>
214-
</html>
215-
`, {
216-
headers: { 'Content-Type': 'text/html' },
217-
status: isError ? 400 : 200
218-
});
219-
};
281+
Here's [an example page](https://tools.simonwillison.net/github-auth?code=bad-code) showing the new error message.
220282

221-
try {
222-
const url = new URL(request.url);
223-
const clientId = env.GITHUB_CLIENT_ID;
224-
const clientSecret = env.GITHUB_CLIENT_SECRET;
225-
const redirectUri = env.GITHUB_REDIRECT_URI;
226-
227-
if (!url.searchParams.has('code')) {
228-
const githubAuthUrl = new URL('https://github.com/login/oauth/authorize');
229-
githubAuthUrl.searchParams.set('client_id', clientId);
230-
githubAuthUrl.searchParams.set('redirect_uri', redirectUri);
231-
githubAuthUrl.searchParams.set('scope', 'gist');
232-
githubAuthUrl.searchParams.set('state', crypto.randomUUID());
233-
return Response.redirect(githubAuthUrl.toString(), 302);
234-
}
283+
## Preventing CSRF attacks
235284

236-
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
237-
method: 'POST',
238-
headers: {
239-
'Content-Type': 'application/json',
240-
'Accept': 'application/json'
241-
},
242-
body: JSON.stringify({
243-
client_id: clientId,
244-
client_secret: clientSecret,
245-
code: url.searchParams.get('code'),
246-
redirect_uri: redirectUri
247-
})
248-
});
249-
250-
const tokenData = await tokenResponse.json();
251-
252-
if (tokenData.error) {
253-
return generateHTML({
254-
title: 'GitHub OAuth Error',
255-
content: `
256-
<h3>Authentication Error</h3>
257-
<p>Error: ${tokenData.error}</p>
258-
${tokenData.error_description ? `<p>Description: ${tokenData.error_description}</p>` : ''}
259-
`,
260-
isError: true
261-
});
262-
}
263-
264-
return generateHTML({
265-
title: 'GitHub OAuth Success',
266-
content: `
267-
<h2>Authentication successful!</h2>
268-
<p>You can close this window.</p>
269-
<script>
270-
try {
271-
localStorage.setItem('github_token', '${tokenData.access_token}');
272-
} catch (err) {
273-
document.body.innerHTML += '<p style="color: #c62828;">Warning: Unable to store token in localStorage</p>';
274-
}
275-
</script>
276-
`
277-
});
285+
Robert Munteanu [on Mastodon](https://fosstodon.org/@rombert/113566295983095957):
286+
287+
> I have looked into OAuth 2.0 and OIDC recently, I wonder what your toughts are about adding CSRF protection? There seems to be no checking of the state parameter in the workers code.
288+
>
289+
> https://www.rfc-editor.org/rfc/rfc6749#section-10.12
290+
291+
He's absolutely right! The `state=` parameter was being set but in the first few versions of the code it wasn't being checked later.
292+
293+
### Understanding the attack
294+
295+
I started thinking this through with the help of Claude: I [pasted in the code](https://gist.github.com/simonw/e87e55dfe13e7201dc0ae5042bc4d4eb) and prompted:
296+
297+
> `Explain the consequences of this not checking the state parameter`
298+
299+
Some back and forth I'm ready to explain this in my own words.
300+
301+
The specific attack to worry about here is one where an attacker tricks/forces a user into signing in to an account that the _attacker_ controls.
302+
303+
For my Gist example here, imagine if I could create a brand new GitHub account and then trick you into signing in to that account using OAuth, while giving you the impression that you had signed into your own account.
304+
305+
If the user saves a Gist containing their private information, you can now access that as the real owner of the account.
306+
307+
With GitHub OAuth here's how that could happen: as an attacker, I could initiate the OAuth flow against my new dedicated malicious account, and then at the end intercept that final redirect URL with the `?code=` parameter:
278308

279-
} catch (error) {
280-
return generateHTML({
281-
title: 'Unexpected Error',
282-
content: `
283-
<h3>Unexpected Error</h3>
284-
<p>An unexpected error occurred during authentication.</p>
285-
<p>Details: ${error.message}</p>
286-
`,
287-
isError: true
288-
});
289-
}
290-
}
291-
};
292309
```
293-
Here's [an example page](https://tools.simonwillison.net/github-auth?code=bad-code) showing the new error message.
310+
https://tools.simonwillison.net/github-auth?code=auth-code-I-generated
311+
```
312+
Rather than visit that URL I instead send it to my target and trick them into clicking on it.
313+
314+
When they visit that page the Worker exchanges that `?code=` for an access token against MY account and stores that in my victim's `localStorage`.
315+
316+
Now any Gists they save will be visible to me as the account's real owner.
317+
318+
### How to prevent it
319+
320+
This attack is why the OAuth specification describes the `&state=` parameter. This is a random value that the client generates and then sends to the server. The server echoes that value back to the client in the final redirect URL as the `?state=` parameter.
321+
322+
To avoid CSRF attacks, we need to record that initial generated `state` and then compare it to the `state=` in the final redirect URL.
323+
324+
I pasted in another copy of my script and prompted Claude:
325+
326+
> `Modify this to store the state= parameter in an HTTP only session cookie called github_auth_state and then compare that when the user comes back and show an error if they do not match, otherwise unset the cookie and complete the operation`
327+
328+
Claude [wrote some code](https://gist.github.com/simonw/ae56f00572dd80f9180687f9532a8226#create-github-oauth-worker-with-state-validation), but when I tried it out on Cloudflare I got this error... which I pasted back into Claude as a follow-up prompt:
329+
330+
> `Unexpected Error An unexpected error occurred during authentication. Details: Can't modify immutable headers.`
331+
332+
This time it [wrote code that worked](https://gist.github.com/simonw/ae56f00572dd80f9180687f9532a8226#rewrite-untitled) - it turns out the correct pattern for sending custom HTTP headers in a Cloudflare Worker looks like this:
333+
334+
```javascript
335+
return new Response(`
336+
<!DOCTYPE html>
337+
<html>...</html>
338+
`, {
339+
headers: {
340+
'Content-Type': 'text/html',
341+
...headers
342+
},
343+
);
344+
```

0 commit comments

Comments
 (0)