Skip to content

Commit e051d6a

Browse files
committed
Wrap every handler to escape HTML, not just callback
1 parent 7a84581 commit e051d6a

File tree

12 files changed

+178
-78
lines changed

12 files changed

+178
-78
lines changed

src/auth0-session/handlers/callback.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,6 @@ 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-
2514
export type AfterCallback = (req: any, res: any, session: any, state: Record<string, any>) => Promise<any> | any;
2615

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

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

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: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,6 @@ 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-
250242
it("should expose all tokens when id_token is valid and response_type is 'code id_token'", async () => {
251243
const baseURL = await setup({
252244
...defaultConfig,

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: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ describe('callback handler', () => {
9191

9292
it('should escape html in error qp', async () => {
9393
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;'
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;'
9696
);
9797
});
9898

@@ -329,7 +329,9 @@ describe('callback handler', () => {
329329
},
330330
cookieJar
331331
)
332-
).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+
);
333335
});
334336

335337
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)