Skip to content

Commit 7a84581

Browse files
committed
Fix reflected XSS from the callback handler's error query parameter
1 parent 36655df commit 7a84581

File tree

4 files changed

+46
-2
lines changed

4 files changed

+46
-2
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The Auth0 Next.js SDK is a library for implementing user authentication in Next.
1717
- [API Reference](#api-reference)
1818
- [v1 Migration Guide](./V1_MIGRATION_GUIDE.md)
1919
- [Cookies and Security](#cookies-and-security)
20+
- [Error Handling and Security](#error-handling-and-security)
2021
- [Base Path and Internationalized Routing](#base-path-and-internationalized-routing)
2122
- [Architecture](./ARCHITECTURE.md)
2223
- [Comparison with auth0-react](#comparison-with-auth0-react)
@@ -188,6 +189,22 @@ The `HttpOnly` setting will make sure that client-side JavaScript is unable to a
188189

189190
The `SameSite=Lax` setting will help mitigate CSRF attacks. Learn more about SameSite by reading the ["Upcoming Browser Behavior Changes: What Developers Need to Know"](https://auth0.com/blog/browser-behavior-changes-what-developers-need-to-know/) blog post.
190191

192+
### Error Handling and Security
193+
194+
The default server side error handler for the `/api/auth/*` routes prints the error message to screen, eg
195+
196+
```js
197+
try {
198+
await handler(req, res);
199+
} catch (error) {
200+
res.status(error.status || 400).end(error.message);
201+
}
202+
```
203+
204+
Because the error can come from the OpenID Connect `error` query parameter we do some [basic escaping](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-1-html-encode-before-inserting-untrusted-data-into-html-element-content) which makes sure the default error handler is safe from XSS.
205+
206+
If you write your own error handler, you should **not** render the error message without using a templating engine that will properly escape it for other HTML contexts first.
207+
191208
### Base Path and Internationalized Routing
192209

193210
With Next.js you can deploy a Next.js application under a sub-path of a domain using [Base Path](https://nextjs.org/docs/api-reference/next.config.js/basepath) and serve internationalized (i18n) routes using [Internationalized Routing](https://nextjs.org/docs/advanced-features/i18n-routing).

src/auth0-session/handlers/callback.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ function getRedirectUri(config: Config): string {
1111
return urlJoin(config.baseURL, config.routes.callback);
1212
}
1313

14+
// eslint-disable-next-line max-len
15+
// Basic escaping for putting untrusted data directly into the HTML body, per: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-1-html-encode-before-inserting-untrusted-data-into-html-element-content
16+
function htmlSafe(input: string): string {
17+
return input
18+
.replace(/&/g, '&')
19+
.replace(/</g, '&lt;')
20+
.replace(/>/g, '&gt;')
21+
.replace(/"/g, '&quot;')
22+
.replace(/'/g, '&#39;');
23+
}
24+
1425
export type AfterCallback = (req: any, res: any, session: any, state: Record<string, any>) => Promise<any> | any;
1526

1627
export type CallbackOptions = {
@@ -47,7 +58,8 @@ export default function callbackHandlerFactory(
4758
state: expectedState
4859
});
4960
} catch (err) {
50-
throw new BadRequest(err.message);
61+
// The error message can come from the route's query parameters, so do some basic escaping.
62+
throw new BadRequest(htmlSafe(err.message));
5163
}
5264

5365
const openidState: { returnTo?: string } = decodeState(expectedState as string);

tests/auth0-session/handlers/callback.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@ describe('callback', () => {
239239
expect(session.claims).toEqual(expect.objectContaining(expected));
240240
});
241241

242+
it('should escape html in error qp', async () => {
243+
const baseURL = await setup(defaultConfig);
244+
245+
await expect(get(baseURL, '/callback?error=<script>alert(1)</script>')).rejects.toThrowError(
246+
'&lt;script&gt;alert(1)&lt;/script&gt;'
247+
);
248+
});
249+
242250
it("should expose all tokens when id_token is valid and response_type is 'code id_token'", async () => {
243251
const baseURL = await setup({
244252
...defaultConfig,
@@ -377,7 +385,7 @@ describe('callback', () => {
377385
const redirectUri = 'http://messi:3000/api/auth/callback/runtime';
378386
const baseURL = await setup(defaultConfig, { callbackOptions: { redirectUri } });
379387
const state = encodeState({ foo: 'bar' });
380-
const cookieJar = toSignedCookieJar( { state, nonce: '__test_nonce__' }, baseURL);
388+
const cookieJar = toSignedCookieJar({ state, nonce: '__test_nonce__' }, baseURL);
381389
const { res } = await post(baseURL, '/callback', {
382390
body: {
383391
state: state,

tests/handlers/callback.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ describe('callback handler', () => {
8989
).rejects.toThrow('unexpected iss value, expected https://acme.auth0.local/, got: other-issuer');
9090
});
9191

92+
it('should escape html in error qp', async () => {
93+
const baseUrl = await setup(withoutApi);
94+
await expect(get(baseUrl, `/api/auth/callback?error=<script>alert(1)</script>`)).rejects.toThrow(
95+
'&lt;script&gt;alert(1)&lt;/script&gt;'
96+
);
97+
});
98+
9299
test('should create the session without OIDC claims', async () => {
93100
const baseUrl = await setup(withoutApi);
94101
const state = encodeState({ returnTo: baseUrl });

0 commit comments

Comments
 (0)