Repository navigation
Expand file tree
/
Copy pathapp.ts
More file actions
192 lines (160 loc) · 6.55 KB
/
Copy pathapp.ts
File metadata and controls
192 lines (160 loc) · 6.55 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/
import cors, { CorsOptionsDelegate } from 'cors';
import express, { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import swaggerUi from 'swagger-ui-express';
import { mountAdminDashboard } from './lib/adminDashboard.js';
import { loadRoutes } from './lib/loadRoutes.js';
import { dynamicRateLimit } from './middleware/rateLimit.js';
import { logRoute } from './middleware/routeLogger.js';
import { dynamicSlowDown } from './middleware/slowDown.js';
import { applyTrustedClientContext } from './middleware/trustedClientContext.js';
import { generateOpenApiDocument } from './openapi/document.js';
import { AuthEventService } from './services/authEventService.js';
import getLogger from './utils/logger.js';
const logger = getLogger('app');
const app = express();
// Express ignores X-Forwarded-For unless it is told how far to trust it, so behind a load
// balancer every request looks like it came from the balancer and the rate limiters bucket
// all clients together. Opt in per deployment: direct-to-internet installs must leave this
// unset, otherwise a client could forge the header and choose its own rate-limit bucket.
const trustProxy = process.env.TRUST_PROXY;
if (trustProxy) {
const hops = Number(trustProxy);
app.set('trust proxy', Number.isNaN(hops) ? trustProxy : hops);
}
// Express 5 changed the default query parser from "extended" to "simple". Pinned rather
// than inherited so an upgrade never silently changes how a caller's query string parses:
// this API is the contract source for the SDKs and the console, and none of them opted
// into a narrower parser.
app.set('query parser', 'extended');
const rawOrigin = process.env.APP_ORIGINS!.split(',');
export const CORS_REJECTION = 'Not allowed by CORS';
/**
* Whether a request came from the host this server is itself being served on.
*
* A browser sends `Origin` on every state-changing request, same-origin ones
* included, so the allowlist would otherwise refuse the admin console at
* `/console`, which is served from this API's own origin and documented as
* needing no CORS configuration.
*
* Host only, not scheme: behind a TLS-terminating proxy `req.protocol` reads
* `http` unless `TRUST_PROXY` is set, and the console must not depend on that
* being configured. Nothing is given away by the looser comparison, since anyone
* able to serve content on this host has already won, and a caller that can forge
* `Host` can equally send no `Origin` at all, which was always allowed.
*/
function isSameOrigin(req: Request, origin: string) {
const host = req.get('host');
if (!host) {
return false;
}
try {
return new URL(origin).host === host;
} catch {
return false;
}
}
// A delegate rather than a plain `origin` function because that form is handed
// no request, and same-origin has to be told apart from cross-origin.
const corsOptionsDelegate: CorsOptionsDelegate<Request> = (req, callback) => {
const origin = req.headers.origin;
if (!origin || rawOrigin.includes(origin) || isSameOrigin(req, origin)) {
return callback(null, { origin: true, credentials: true });
}
logger.warn(`Unknown CORS origin: ${origin}`);
// Refused with an error rather than by omitting the response header. Omitting
// it leaves the browser to discard a response the route has already produced,
// which means a disallowed origin still executes the request.
return callback(new Error(CORS_REJECTION));
};
app.use(
helmet({
hidePoweredBy: true,
frameguard: { action: 'deny' },
xssFilter: true,
noSniff: true,
}),
);
const isDev = process.env.NODE_ENV !== 'production';
app.use(applyTrustedClientContext);
if (isDev) {
app.get('/openapi.json', (_req, res) => {
const document = generateOpenApiDocument();
res.json(document);
});
app.use(
'/docs',
swaggerUi.serve,
swaggerUi.setup(undefined, {
swaggerOptions: {
url: '/openapi.json',
},
}),
);
}
if (process.env.NODE_ENV !== 'test') {
app.use(dynamicSlowDown);
app.use(dynamicRateLimit);
}
// Responses carry tokens, client secrets, recovery codes and delivery payloads, so none is
// stored by a cache between the caller and the API unless its route says otherwise (JWKS,
// hashed dashboard assets).
app.use((_req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
app.use(express.json());
app.use(cors<Request>(corsOptionsDelegate));
app.use(logRoute);
export async function createApp() {
await loadRoutes(app);
// Additive static serving for the admin dashboard SPA at /console. Registered after the
// API routes so the API always keeps priority, and before the 404 handler so unmatched
// /console/* navigations fall back to the SPA instead of 404ing.
mountAdminDashboard(app);
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err.message === CORS_REJECTION) {
// Recorded here rather than in the delegate so the event carries the real
// client address and user agent, with the origin in a field that says origin.
void AuthEventService.requestSuspicious(req, {
reason: 'Request from an unexpected origin',
origin: req.headers.origin ?? null,
});
// No Access-Control-Allow-Origin: naming an allowed origin to a caller that
// is not one tells it part of the allowlist and helps the browser not at all.
return res.status(403).json({ message: 'CORS policy does not allow this origin.' });
}
// next(err), not next(): a bare next() from an error handler clears the error and
// resumes at the next regular middleware, which skips the 500 handler below and lands
// on the 404. That reported server faults as unknown routes and recorded the caller
// for them.
return next(err);
});
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (err) {
logger.error('Unhandled error', err);
return res.status(500).json({
error: 'Internal server error',
});
}
return next();
});
app.use((req: Request, res: Response) => {
logger.warn(
`[${req.ip}] ${req.method} ${req.originalUrl} did not match any route. Tracking suspicious behavior`,
);
void AuthEventService.requestSuspicious(req, {
reason: 'Request to an unknown route.',
method: req.method,
path: req.originalUrl,
});
return res.status(404).json({ error: 'Not Found' });
});
return app;
}
export default app;