Skip to content

Commit 406793a

Browse files
committed
Ensure all API endpoints are safe against uncaught promise rejections
1 parent 2debac1 commit 406793a

File tree

7 files changed

+179
-148
lines changed

7 files changed

+179
-148
lines changed
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import type { RequestHandler } from 'express';
22

3-
export function safe<H extends RequestHandler>(handler: H): H {
4-
return (async (req, res, next) => {
3+
// express 4.x does not handle asynchronous errors - if an asynchronous operation fails, it will crash the server
4+
// we defend against this by wrapping all async handlers in this function, which mimics the behaviour of express 5.x
5+
export function safe<P = unknown>(
6+
handler: RequestHandler<P>,
7+
): RequestHandler<P> {
8+
return async (req, res, next) => {
59
try {
610
await handler(req, res, next);
711
} catch (e) {
812
next(e);
913
}
10-
}) as H;
14+
};
1115
}

backend/src/routers/ApiAuthRouter.ts

Lines changed: 45 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { WebSocketExpress, Router, type JWTPayload } from 'websocket-express';
22
import { type RetroAuthService } from '../services/RetroAuthService';
33
import { type UserAuthService } from '../services/UserAuthService';
44
import { type RetroService } from '../services/RetroService';
5+
import { safe } from '../helpers/routeHelpers';
56

67
const JSON_BODY = WebSocketExpress.json({ limit: 4 * 1024 });
78

@@ -18,41 +19,49 @@ export class ApiAuthRouter extends Router {
1819
(token): JWTPayload | null => userAuthService.readAndVerifyToken(token),
1920
);
2021

21-
this.get('/tokens/:retroId/user', userAuthMiddleware, async (req, res) => {
22-
const userId = WebSocketExpress.getAuthData(res).sub!;
23-
const { retroId } = req.params;
24-
25-
if (
26-
!retroId ||
27-
!(await retroService.isRetroOwnedByUser(retroId, userId))
28-
) {
29-
res.status(403).json({ error: 'not retro owner' });
30-
return;
31-
}
32-
33-
const retroToken = await retroAuthService.grantOwnerToken(retroId);
34-
if (!retroToken) {
35-
res.status(500).json({ error: 'retro not found' });
36-
return;
37-
}
38-
39-
res.status(200).json({ retroToken });
40-
});
41-
42-
this.post('/tokens/:retroId', JSON_BODY, async (req, res) => {
43-
const { retroId } = req.params;
44-
const { password } = req.body;
45-
46-
const retroToken = await retroAuthService.grantForPassword(
47-
retroId,
48-
password,
49-
);
50-
if (!retroToken) {
51-
res.status(400).json({ error: 'incorrect password' });
52-
return;
53-
}
54-
55-
res.status(200).json({ retroToken });
56-
});
22+
this.get(
23+
'/tokens/:retroId/user',
24+
userAuthMiddleware,
25+
safe<{ retroId: string }>(async (req, res) => {
26+
const userId = WebSocketExpress.getAuthData(res).sub!;
27+
const { retroId } = req.params;
28+
29+
if (
30+
!retroId ||
31+
!(await retroService.isRetroOwnedByUser(retroId, userId))
32+
) {
33+
res.status(403).json({ error: 'not retro owner' });
34+
return;
35+
}
36+
37+
const retroToken = await retroAuthService.grantOwnerToken(retroId);
38+
if (!retroToken) {
39+
res.status(500).json({ error: 'retro not found' });
40+
return;
41+
}
42+
43+
res.status(200).json({ retroToken });
44+
}),
45+
);
46+
47+
this.post(
48+
'/tokens/:retroId',
49+
JSON_BODY,
50+
safe<{ retroId: string }>(async (req, res) => {
51+
const { retroId } = req.params;
52+
const { password } = req.body;
53+
54+
const retroToken = await retroAuthService.grantForPassword(
55+
retroId,
56+
password,
57+
);
58+
if (!retroToken) {
59+
res.status(400).json({ error: 'incorrect password' });
60+
return;
61+
}
62+
63+
res.status(200).json({ retroToken });
64+
}),
65+
);
5766
}
5867
}
Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,36 @@
11
import { Router } from 'websocket-express';
22
import { type GiphyService } from '../services/GiphyService';
33
import { logError } from '../log';
4+
import { safe } from '../helpers/routeHelpers';
45

56
export class ApiGiphyRouter extends Router {
67
public constructor(service: GiphyService) {
78
super();
89

9-
this.get('/search', async (req, res) => {
10-
const { q, lang = 'en' } = req.query;
10+
this.get(
11+
'/search',
12+
safe(async (req, res) => {
13+
const { q, lang = 'en' } = req.query;
1114

12-
if (typeof q !== 'string' || !q) {
13-
res.status(400).json({ error: 'Bad request' });
14-
return;
15-
}
15+
if (typeof q !== 'string' || !q) {
16+
res.status(400).json({ error: 'Bad request' });
17+
return;
18+
}
1619

17-
if (typeof lang !== 'string') {
18-
res.status(400).json({ error: 'Bad request' });
19-
return;
20-
}
20+
if (typeof lang !== 'string') {
21+
res.status(400).json({ error: 'Bad request' });
22+
return;
23+
}
2124

22-
try {
23-
const gifs = await service.search(q, 10, lang);
25+
try {
26+
const gifs = await service.search(q, 10, lang);
2427

25-
res.json({ gifs });
26-
} catch (err) {
27-
logError('Giphy proxy error', err);
28-
res.status(500).json({ error: 'Proxy error' });
29-
}
30-
});
28+
res.json({ gifs });
29+
} catch (err) {
30+
logError('Giphy proxy error', err);
31+
res.status(500).json({ error: 'Proxy error' });
32+
}
33+
}),
34+
);
3135
}
3236
}
Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Router } from 'websocket-express';
22
import { type PasswordCheckService } from '../services/PasswordCheckService';
33
import { logError } from '../log';
4+
import { safe } from '../helpers/routeHelpers';
45

56
const VALID_RANGE = /^[0-9A-Z]{5}$/;
67

@@ -15,32 +16,35 @@ export class ApiPasswordCheckRouter extends Router {
1516
public constructor(service: PasswordCheckService) {
1617
super();
1718

18-
this.get('/:range', async (req, res) => {
19-
const { range } = req.params;
19+
this.get(
20+
'/:range',
21+
safe<{ range: string }>(async (req, res) => {
22+
const { range } = req.params;
2023

21-
if (!VALID_RANGE.test(range)) {
22-
res.status(400).end();
23-
}
24-
25-
try {
26-
const data = await service.getBreachesRange(range);
27-
res.header('cache-control', CACHE_CONTROL);
28-
res.removeHeader('expires');
29-
res.removeHeader('pragma');
30-
res.end(data);
31-
} catch (err) {
32-
if (err instanceof Error && err.message === 'Invalid range prefix') {
24+
if (!VALID_RANGE.test(range)) {
3325
res.status(400).end();
34-
} else if (
35-
err instanceof Error &&
36-
err.message === 'Service unavailable'
37-
) {
38-
res.status(503).end();
39-
} else {
40-
logError('Password breaches lookup error', err);
41-
res.status(500).end();
4226
}
43-
}
44-
});
27+
28+
try {
29+
const data = await service.getBreachesRange(range);
30+
res.header('cache-control', CACHE_CONTROL);
31+
res.removeHeader('expires');
32+
res.removeHeader('pragma');
33+
res.end(data);
34+
} catch (err) {
35+
if (err instanceof Error && err.message === 'Invalid range prefix') {
36+
res.status(400).end();
37+
} else if (
38+
err instanceof Error &&
39+
err.message === 'Service unavailable'
40+
) {
41+
res.status(503).end();
42+
} else {
43+
logError('Password breaches lookup error', err);
44+
res.status(500).end();
45+
}
46+
}
47+
}),
48+
);
4549
}
4650
}

backend/src/routers/ApiRetroArchivesRouter.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { WebSocketExpress, Router } from 'websocket-express';
22
import { type RetroArchiveService } from '../services/RetroArchiveService';
33
import { extractRetroData } from '../helpers/jsonParsers';
44
import { logError } from '../log';
5+
import { safe } from '../helpers/routeHelpers';
56

67
const JSON_BODY = WebSocketExpress.json({ limit: 512 * 1024 });
78

@@ -12,20 +13,20 @@ export class ApiRetroArchivesRouter extends Router {
1213
this.get(
1314
'/',
1415
WebSocketExpress.requireAuthScope('readArchives'),
15-
async (req, res) => {
16+
safe<{ retroId: string }>(async (req, res) => {
1617
const { retroId } = req.params;
1718

1819
const archives =
1920
await retroArchiveService.getRetroArchiveSummaries(retroId);
2021
res.json({ archives });
21-
},
22+
}),
2223
);
2324

2425
this.post(
2526
'/',
2627
WebSocketExpress.requireAuthScope('write'),
2728
JSON_BODY,
28-
async (req, res) => {
29+
safe<{ retroId: string }>(async (req, res) => {
2930
try {
3031
const { retroId } = req.params;
3132
const data = extractRetroData(req.body);
@@ -46,13 +47,13 @@ export class ApiRetroArchivesRouter extends Router {
4647
res.status(400).json({ error: e.message });
4748
}
4849
}
49-
},
50+
}),
5051
);
5152

5253
this.get(
5354
'/:archiveId',
5455
WebSocketExpress.requireAuthScope('readArchives'),
55-
async (req, res) => {
56+
safe<{ retroId: string; archiveId: string }>(async (req, res) => {
5657
const { retroId, archiveId } = req.params;
5758

5859
const archive = await retroArchiveService.getRetroArchive(
@@ -65,7 +66,7 @@ export class ApiRetroArchivesRouter extends Router {
6566
} else {
6667
res.status(404).end();
6768
}
68-
},
69+
}),
6970
);
7071
}
7172
}

0 commit comments

Comments
 (0)