Skip to content

Commit 6996e25

Browse files
authored
Merge pull request from GHSA-954c-jjx6-cxv7
Fix reflected XSS from the callback handler's error query parameter
2 parents 36655df + e051d6a commit 6996e25

File tree

12 files changed

+200
-56
lines changed

12 files changed

+200
-56
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/handlers/callback.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HandleCallback as BaseHandleCallback } from '../auth0-session';
44
import { Session } from '../session';
55
import { assertReqRes } from '../utils/assert';
66
import { NextConfig } from '../config';
7+
import { HandlerError } from '../utils/errors';
78

89
/**
910
* Use this function for validating additional claims on the user's ID Token or adding removing items from
@@ -122,10 +123,14 @@ const idTokenValidator = (afterCallback?: AfterCallback, organization?: string):
122123
*/
123124
export default function handleCallbackFactory(handler: BaseHandleCallback, config: NextConfig): HandleCallback {
124125
return async (req, res, options = {}): Promise<void> => {
125-
assertReqRes(req, res);
126-
return handler(req, res, {
127-
...options,
128-
afterCallback: idTokenValidator(options.afterCallback, options.organization || config.organization)
129-
});
126+
try {
127+
assertReqRes(req, res);
128+
return await handler(req, res, {
129+
...options,
130+
afterCallback: idTokenValidator(options.afterCallback, options.organization || config.organization)
131+
});
132+
} catch (e) {
133+
throw new HandlerError(e);
134+
}
130135
};
131136
}

src/handlers/login.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AuthorizationParameters, HandleLogin as BaseHandleLogin } from '../auth
33
import isSafeRedirect from '../utils/url-helpers';
44
import { assertReqRes } from '../utils/assert';
55
import { NextConfig } from '../config';
6+
import { HandlerError } from '../utils/errors';
67

78
/**
89
* Use this to store additional state for the user before they visit the Identity Provider to login.
@@ -109,23 +110,27 @@ export type HandleLogin = (req: NextApiRequest, res: NextApiResponse, options?:
109110
*/
110111
export default function handleLoginFactory(handler: BaseHandleLogin, nextConfig: NextConfig): HandleLogin {
111112
return async (req, res, options = {}): Promise<void> => {
112-
assertReqRes(req, res);
113-
if (req.query.returnTo) {
114-
const returnTo = Array.isArray(req.query.returnTo) ? req.query.returnTo[0] : req.query.returnTo;
113+
try {
114+
assertReqRes(req, res);
115+
if (req.query.returnTo) {
116+
const returnTo = Array.isArray(req.query.returnTo) ? req.query.returnTo[0] : req.query.returnTo;
115117

116-
if (!isSafeRedirect(returnTo)) {
117-
throw new Error('Invalid value provided for returnTo, must be a relative url');
118+
if (!isSafeRedirect(returnTo)) {
119+
throw new Error('Invalid value provided for returnTo, must be a relative url');
120+
}
121+
122+
options = { ...options, returnTo };
123+
}
124+
if (nextConfig.organization) {
125+
options = {
126+
...options,
127+
authorizationParams: { organization: nextConfig.organization, ...options.authorizationParams }
128+
};
118129
}
119130

120-
options = { ...options, returnTo };
131+
return await handler(req, res, options);
132+
} catch (e) {
133+
throw new HandlerError(e);
121134
}
122-
if (nextConfig.organization) {
123-
options = {
124-
...options,
125-
authorizationParams: { organization: nextConfig.organization, ...options.authorizationParams }
126-
};
127-
}
128-
129-
return handler(req, res, options);
130135
};
131136
}

src/handlers/logout.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextApiResponse, NextApiRequest } from 'next';
22
import { HandleLogout as BaseHandleLogout } from '../auth0-session';
33
import { assertReqRes } from '../utils/assert';
4+
import { HandlerError } from '../utils/errors';
45

56
/**
67
* Custom options to pass to logout.
@@ -27,7 +28,11 @@ export type HandleLogout = (req: NextApiRequest, res: NextApiResponse, options?:
2728
*/
2829
export default function handleLogoutFactory(handler: BaseHandleLogout): HandleLogout {
2930
return async (req, res, options): Promise<void> => {
30-
assertReqRes(req, res);
31-
return handler(req, res, options);
31+
try {
32+
assertReqRes(req, res);
33+
return await handler(req, res, options);
34+
} catch (e) {
35+
throw new HandlerError(e);
36+
}
3237
};
3338
}

src/handlers/profile.ts

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextApiResponse, NextApiRequest } from 'next';
22
import { ClientFactory } from '../auth0-session';
33
import { SessionCache, Session, fromJson, GetAccessToken } from '../session';
44
import { assertReqRes } from '../utils/assert';
5+
import { HandlerError } from '../utils/errors';
56

67
export type AfterRefetch = (req: NextApiRequest, res: NextApiResponse, session: Session) => Promise<Session> | Session;
78

@@ -39,46 +40,50 @@ export default function profileHandler(
3940
sessionCache: SessionCache
4041
): HandleProfile {
4142
return async (req, res, options): Promise<void> => {
42-
assertReqRes(req, res);
43+
try {
44+
assertReqRes(req, res);
4345

44-
if (!sessionCache.isAuthenticated(req, res)) {
45-
res.status(401).json({
46-
error: 'not_authenticated',
47-
description: 'The user does not have an active session or is not authenticated'
48-
});
49-
return;
50-
}
46+
if (!sessionCache.isAuthenticated(req, res)) {
47+
res.status(401).json({
48+
error: 'not_authenticated',
49+
description: 'The user does not have an active session or is not authenticated'
50+
});
51+
return;
52+
}
5153

52-
const session = sessionCache.get(req, res) as Session;
53-
res.setHeader('Cache-Control', 'no-store');
54+
const session = sessionCache.get(req, res) as Session;
55+
res.setHeader('Cache-Control', 'no-store');
5456

55-
if (options?.refetch) {
56-
const { accessToken } = await getAccessToken(req, res);
57-
if (!accessToken) {
58-
throw new Error('No access token available to refetch the profile');
59-
}
57+
if (options?.refetch) {
58+
const { accessToken } = await getAccessToken(req, res);
59+
if (!accessToken) {
60+
throw new Error('No access token available to refetch the profile');
61+
}
6062

61-
const client = await getClient();
62-
const userInfo = await client.userinfo(accessToken);
63+
const client = await getClient();
64+
const userInfo = await client.userinfo(accessToken);
6365

64-
let newSession = fromJson({
65-
...session,
66-
user: {
67-
...session.user,
68-
...userInfo
66+
let newSession = fromJson({
67+
...session,
68+
user: {
69+
...session.user,
70+
...userInfo
71+
}
72+
}) as Session;
73+
74+
if (options.afterRefetch) {
75+
newSession = await options.afterRefetch(req, res, newSession);
6976
}
70-
}) as Session;
7177

72-
if (options.afterRefetch) {
73-
newSession = await options.afterRefetch(req, res, newSession);
74-
}
78+
sessionCache.set(req, res, newSession);
7579

76-
sessionCache.set(req, res, newSession);
80+
res.json(newSession.user);
81+
return;
82+
}
7783

78-
res.json(newSession.user);
79-
return;
84+
res.json(session.user);
85+
} catch (e) {
86+
throw new HandlerError(e);
8087
}
81-
82-
res.json(session.user);
8388
};
8489
}

src/utils/errors.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { HttpError } from 'http-errors';
2+
13
/**
24
* The error thrown by {@link GetAccessToken}
35
*
@@ -19,3 +21,44 @@ export class AccessTokenError extends Error {
1921
this.code = code;
2022
}
2123
}
24+
25+
// eslint-disable-next-line max-len
26+
// 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
27+
function htmlSafe(input: string): string {
28+
return input
29+
.replace(/&/g, '&amp;')
30+
.replace(/</g, '&lt;')
31+
.replace(/>/g, '&gt;')
32+
.replace(/"/g, '&quot;')
33+
.replace(/'/g, '&#39;');
34+
}
35+
36+
/**
37+
* The error thrown by API route handlers.
38+
*
39+
* Because the error message can come from the OpenID Connect `error` query parameter we
40+
* do some basic escaping which makes sure the default error handler is safe from XSS.
41+
*
42+
* If you write your own error handler, you should **not** render the error message
43+
* without using a templating engine that will properly escape it for other HTML contexts first.
44+
*
45+
* @category Server
46+
*/
47+
export class HandlerError extends Error {
48+
public status: number | undefined;
49+
public code: string | undefined;
50+
51+
constructor(error: Error | AccessTokenError | HttpError) {
52+
super(htmlSafe(error.message));
53+
54+
this.name = error.name;
55+
56+
if ('code' in error) {
57+
this.code = error.code;
58+
}
59+
60+
if ('status' in error) {
61+
this.status = error.status;
62+
}
63+
}
64+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ describe('callback', () => {
377377
const redirectUri = 'http://messi:3000/api/auth/callback/runtime';
378378
const baseURL = await setup(defaultConfig, { callbackOptions: { redirectUri } });
379379
const state = encodeState({ foo: 'bar' });
380-
const cookieJar = toSignedCookieJar( { state, nonce: '__test_nonce__' }, baseURL);
380+
const cookieJar = toSignedCookieJar({ state, nonce: '__test_nonce__' }, baseURL);
381381
const { res } = await post(baseURL, '/callback', {
382382
body: {
383383
state: state,

tests/fixtures/oidc-nocks.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import { ConfigParameters } from '../../src';
44
import { makeIdToken } from '../auth0-session/fixtures/cert';
55

66
export function discovery(params: ConfigParameters, discoveryOptions?: any): nock.Scope {
7+
const { error, ...metadata } = discoveryOptions || {};
8+
9+
if (error) {
10+
return nock(params.issuerBaseURL as string)
11+
.get('/.well-known/openid-configuration')
12+
.reply(500, { error })
13+
.get('/.well-known/oauth-authorization-server')
14+
.reply(500, { error });
15+
}
16+
717
return nock(params.issuerBaseURL as string)
818
.get('/.well-known/openid-configuration')
919
.reply(200, () => {
@@ -50,7 +60,7 @@ export function discovery(params: ConfigParameters, discoveryOptions?: any): noc
5060
'picture',
5161
'sub'
5262
],
53-
...(discoveryOptions || {})
63+
...metadata
5464
};
5565
});
5666
}

tests/handlers/callback.test.ts

Lines changed: 10 additions & 1 deletion
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=%3Cscript%3Ealert(%27xss%27)%3C%2Fscript%3E`)).rejects.toThrow(
95+
'&lt;script&gt;alert(&#39;xss&#39;)&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 });
@@ -322,7 +329,9 @@ describe('callback handler', () => {
322329
},
323330
cookieJar
324331
)
325-
).rejects.toThrow('Organization Id (org_id) claim value mismatch in the ID token; expected "foo", found "bar"');
332+
).rejects.toThrow(
333+
'Organization Id (org_id) claim value mismatch in the ID token; expected &quot;foo&quot;, found &quot;bar&quot;'
334+
);
326335
});
327336

328337
test('accepts a valid organization', async () => {

tests/handlers/login.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ describe('login handler', () => {
244244
).rejects.toThrow('Invalid value provided for returnTo, must be a relative url');
245245
});
246246

247+
test('should escape html in errors', async () => {
248+
const baseUrl = await setup(withoutApi, { discoveryOptions: { error: '<script>alert("xss")</script>' } });
249+
250+
await expect(get(baseUrl, '/api/auth/login', { fullResponse: true })).rejects.toThrow(
251+
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
252+
);
253+
});
254+
247255
test('should allow the returnTo to be be overwritten by getState() when provided in the querystring', async () => {
248256
const loginOptions = {
249257
returnTo: '/profile',

0 commit comments

Comments
 (0)