346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
|
|
import type { Writable } from 'node:stream';
|
|
import Fastify, {
|
|
type FastifyInstance,
|
|
type FastifyServerOptions,
|
|
type FastifyRequest,
|
|
} from 'fastify';
|
|
import { UpstreamError } from './infrastructure/transport/upstream-error.js';
|
|
|
|
export const API_DEFAULT_HOST = '127.0.0.1' as const;
|
|
export const API_DEFAULT_PORT = 8790 as const;
|
|
const LEGACY_PORT = 8788;
|
|
|
|
export const REDACT_PATHS = Object.freeze([
|
|
`req.headers.${GATEWAY_AUTH_HEADER}`,
|
|
'req.headers.authorization',
|
|
'req.headers.cookie',
|
|
'req.headers.x-confirmation-token',
|
|
'req.body.password',
|
|
'req.body.newPassword',
|
|
'req.body.confirmationToken',
|
|
'res.headers.set-cookie',
|
|
]);
|
|
|
|
export interface ReadinessResult {
|
|
readonly ready: boolean;
|
|
readonly checks: Readonly<Record<string, string>>;
|
|
}
|
|
|
|
export interface BuildAppOptions {
|
|
readonly gatewayToken?: string;
|
|
readonly readiness?: () => ReadinessResult | Promise<ReadinessResult>;
|
|
readonly registerRoutes?: (app: FastifyInstance) => void | Promise<void>;
|
|
readonly logger?: false | { readonly stream?: Writable };
|
|
}
|
|
|
|
export function secureTokenEqual(
|
|
supplied: string,
|
|
expected: string,
|
|
compare: (
|
|
actual: NodeJS.ArrayBufferView,
|
|
expected: NodeJS.ArrayBufferView,
|
|
) => boolean = timingSafeEqual,
|
|
): boolean {
|
|
const digest = (value: string): Buffer => createHash('sha256').update(value, 'utf8').digest();
|
|
return compare(digest(supplied), digest(expected));
|
|
}
|
|
|
|
export interface ListenEnvironment {
|
|
readonly API_HOST?: string;
|
|
readonly API_PORT?: string;
|
|
}
|
|
|
|
export interface ListenOptions {
|
|
readonly host: string;
|
|
readonly port: number;
|
|
}
|
|
|
|
interface ValidationIssue {
|
|
readonly field: string;
|
|
readonly code: string;
|
|
readonly message: string;
|
|
}
|
|
|
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
const LOOPBACK_AUTHORITY = /^(?:127\.0\.0\.1|localhost|\[::1\])(?::([1-9]\d{0,4}))?$/i;
|
|
|
|
const authority = (raw: string): { host: string; authority: string } | undefined => {
|
|
const match = LOOPBACK_AUTHORITY.exec(raw);
|
|
if (!match) return undefined;
|
|
const port = match[1] === undefined ? undefined : Number(match[1]);
|
|
if (port !== undefined && port > 65_535) return undefined;
|
|
const separator = raw.lastIndexOf(':');
|
|
const hasPort = match[1] !== undefined;
|
|
const host = hasPort ? raw.slice(0, separator) : raw;
|
|
return { host: host.replace(/^\[|\]$/g, '').toLowerCase(), authority: raw.toLowerCase() };
|
|
};
|
|
|
|
const isLoopbackAuthority = (raw: string): boolean => {
|
|
const parsed = authority(raw);
|
|
return parsed !== undefined && LOOPBACK_HOSTS.has(parsed.host.toLowerCase());
|
|
};
|
|
|
|
const isSameOrigin = (origin: string, host: string): boolean => {
|
|
const parsedHost = authority(host);
|
|
return parsedHost !== undefined && origin.toLowerCase() === `http://${parsedHost.authority}`;
|
|
};
|
|
|
|
const validationIssues = (error: unknown): readonly ValidationIssue[] => {
|
|
if (!error || typeof error !== 'object' || !('validation' in error)) return [];
|
|
const validation = (error as { validation?: unknown }).validation;
|
|
if (!Array.isArray(validation)) return [];
|
|
return validation.map((entry) => {
|
|
const issue = entry as {
|
|
keyword?: unknown;
|
|
instancePath?: unknown;
|
|
message?: unknown;
|
|
params?: { missingProperty?: unknown };
|
|
};
|
|
const instancePath = typeof issue.instancePath === 'string' ? issue.instancePath : '';
|
|
const missingProperty = issue.params?.missingProperty;
|
|
return {
|
|
field:
|
|
instancePath || (typeof missingProperty === 'string' ? `/${missingProperty}` : '/request'),
|
|
code: typeof issue.keyword === 'string' ? issue.keyword : 'validation',
|
|
message: typeof issue.message === 'string' ? issue.message : 'is invalid',
|
|
};
|
|
});
|
|
};
|
|
|
|
const CLIENT_ERROR_TITLES: Readonly<Record<number, string>> = {
|
|
400: 'Bad Request',
|
|
401: 'Unauthorized',
|
|
403: 'Forbidden',
|
|
404: 'Not Found',
|
|
405: 'Method Not Allowed',
|
|
409: 'Conflict',
|
|
412: 'Precondition Failed',
|
|
413: 'Content Too Large',
|
|
415: 'Unsupported Media Type',
|
|
422: 'Unprocessable Content',
|
|
429: 'Too Many Requests',
|
|
};
|
|
|
|
const clientStatus = (error: unknown): number | undefined => {
|
|
if (!error || typeof error !== 'object' || !('statusCode' in error)) return undefined;
|
|
const status = (error as { statusCode?: unknown }).statusCode;
|
|
return typeof status === 'number' && status >= 400 && status < 500 ? status : undefined;
|
|
};
|
|
|
|
const sendProblem = (
|
|
request: FastifyRequest,
|
|
status: number,
|
|
title: string,
|
|
code: string,
|
|
detail: string,
|
|
validation?: readonly ValidationIssue[],
|
|
) => {
|
|
const body: Record<string, unknown> = {
|
|
type: 'about:blank',
|
|
title,
|
|
status,
|
|
code,
|
|
detail,
|
|
requestId: request.id,
|
|
};
|
|
if (validation && validation.length > 0) body.validation = validation;
|
|
return body;
|
|
};
|
|
|
|
export function createListenOptions(environment: ListenEnvironment): ListenOptions {
|
|
const host = environment.API_HOST ?? API_DEFAULT_HOST;
|
|
if (!LOOPBACK_HOSTS.has(host.toLowerCase())) {
|
|
throw new Error('API host must be an explicit loopback address');
|
|
}
|
|
|
|
const rawPort = environment.API_PORT;
|
|
const port = rawPort === undefined ? API_DEFAULT_PORT : Number(rawPort);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
throw new Error('API port must be an integer between 1 and 65535');
|
|
}
|
|
if (port === LEGACY_PORT) throw new Error('API port 8788 is reserved for the legacy service');
|
|
if (port !== API_DEFAULT_PORT) throw new Error('API port is fixed at 8790');
|
|
return { host, port };
|
|
}
|
|
|
|
export function buildApp(options: BuildAppOptions = {}): FastifyInstance {
|
|
const logger =
|
|
options.logger === false || options.logger === undefined
|
|
? false
|
|
: {
|
|
...(options.logger.stream ? { stream: options.logger.stream } : {}),
|
|
redact: {
|
|
paths: [...REDACT_PATHS],
|
|
censor: '[REDACTED]',
|
|
},
|
|
};
|
|
const fastifyOptions: FastifyServerOptions = {
|
|
genReqId: () => randomUUID(),
|
|
logger,
|
|
ajv: {
|
|
customOptions: {
|
|
removeAdditional: false,
|
|
coerceTypes: false,
|
|
},
|
|
},
|
|
};
|
|
const app = Fastify(fastifyOptions);
|
|
const readiness =
|
|
options.readiness ?? (() => ({ ready: false, checks: { bootstrap: 'unconfigured' } }));
|
|
|
|
app.addHook('onRequest', async (request, reply) => {
|
|
reply.header('X-Request-Id', request.id);
|
|
if (options.gatewayToken !== undefined) {
|
|
const supplied = request.headers[GATEWAY_AUTH_HEADER];
|
|
const authenticated = secureTokenEqual(
|
|
typeof supplied === 'string' ? supplied : '',
|
|
options.gatewayToken,
|
|
);
|
|
if (!authenticated) {
|
|
return reply
|
|
.code(401)
|
|
.type('application/problem+json')
|
|
.send(
|
|
sendProblem(
|
|
request,
|
|
401,
|
|
'Unauthorized',
|
|
'GATEWAY_AUTH_REQUIRED',
|
|
'Trusted gateway authentication is required.',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
const host = request.headers.host;
|
|
if (!host || !isLoopbackAuthority(host)) {
|
|
return reply
|
|
.code(403)
|
|
.type('application/problem+json')
|
|
.send(
|
|
sendProblem(
|
|
request,
|
|
403,
|
|
'Forbidden',
|
|
'UNTRUSTED_HOST',
|
|
'The Host header is not allowed.',
|
|
),
|
|
);
|
|
}
|
|
const origin = request.headers.origin;
|
|
if (origin && !isSameOrigin(origin, host)) {
|
|
return reply
|
|
.code(403)
|
|
.type('application/problem+json')
|
|
.send(
|
|
sendProblem(
|
|
request,
|
|
403,
|
|
'Forbidden',
|
|
'CROSS_ORIGIN_REQUEST',
|
|
'The Origin header is not allowed.',
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
app.setErrorHandler((error, request, reply) => {
|
|
const issues = validationIssues(error);
|
|
if (issues.length > 0) {
|
|
return reply
|
|
.code(400)
|
|
.type('application/problem+json')
|
|
.send(
|
|
sendProblem(
|
|
request,
|
|
400,
|
|
'Bad Request',
|
|
'VALIDATION_FAILED',
|
|
'The request did not satisfy the endpoint schema.',
|
|
issues,
|
|
),
|
|
);
|
|
}
|
|
if (error instanceof UpstreamError && error.code !== 'UPSTREAM_REQUEST_INVALID') {
|
|
const status =
|
|
error.code === 'UPSTREAM_INSECURE_AUTH'
|
|
? 400
|
|
: error.code === 'UPSTREAM_TIMEOUT'
|
|
? 504
|
|
: 502;
|
|
const title =
|
|
status === 400 ? 'Bad Request' : status === 504 ? 'Gateway Timeout' : 'Bad Gateway';
|
|
const detail =
|
|
status === 400
|
|
? 'Authentication requires an HTTPS instance origin.'
|
|
: status === 504
|
|
? 'The upstream instance did not respond in time.'
|
|
: 'The upstream instance could not complete the request.';
|
|
return reply
|
|
.code(status)
|
|
.type('application/problem+json')
|
|
.send(sendProblem(request, status, title, error.code, detail));
|
|
}
|
|
const status = clientStatus(error);
|
|
if (status !== undefined) {
|
|
const title = CLIENT_ERROR_TITLES[status] ?? 'Bad Request';
|
|
return reply
|
|
.code(status)
|
|
.type('application/problem+json')
|
|
.send(
|
|
sendProblem(
|
|
request,
|
|
status,
|
|
title,
|
|
`HTTP_${status}`,
|
|
'The request could not be accepted.',
|
|
),
|
|
);
|
|
}
|
|
request.log.error(
|
|
{ errorType: error instanceof Error ? error.name : typeof error },
|
|
'request failed',
|
|
);
|
|
return reply
|
|
.code(500)
|
|
.type('application/problem+json')
|
|
.send(
|
|
sendProblem(
|
|
request,
|
|
500,
|
|
'Internal Server Error',
|
|
'INTERNAL_ERROR',
|
|
'The request could not be completed.',
|
|
),
|
|
);
|
|
});
|
|
|
|
app.setNotFoundHandler((request, reply) =>
|
|
reply
|
|
.code(404)
|
|
.type('application/problem+json')
|
|
.send(
|
|
sendProblem(
|
|
request,
|
|
404,
|
|
'Not Found',
|
|
'NOT_FOUND',
|
|
'The requested resource does not exist.',
|
|
),
|
|
),
|
|
);
|
|
|
|
app.get('/healthz', async () => ({ status: 'ok' }));
|
|
app.get('/readyz', async (_request, reply) => {
|
|
const result = await readiness();
|
|
if (!result.ready) reply.code(503);
|
|
return result;
|
|
});
|
|
|
|
if (options.registerRoutes) {
|
|
app.register(async (scope) => options.registerRoutes?.(scope));
|
|
}
|
|
return app;
|
|
}
|