feat(api): add secure Fastify skeleton

This commit is contained in:
chick
2026-07-16 11:36:24 +08:00
parent a86970d33b
commit 26c1bcc6da
8 changed files with 606 additions and 10 deletions
+279
View File
@@ -0,0 +1,279 @@
import { randomUUID } from 'node:crypto';
import type { Writable } from 'node:stream';
import Fastify, {
type FastifyInstance,
type FastifyServerOptions,
type FastifyRequest,
} from 'fastify';
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.authorization',
'req.headers.cookie',
'req.headers.x-confirmation-token',
'req.body.password',
'req.body.confirmationToken',
'res.headers.set-cookie',
]);
export interface ReadinessResult {
readonly ready: boolean;
readonly checks: Readonly<Record<string, string>>;
}
export interface BuildAppOptions {
readonly readiness?: () => ReadinessResult | Promise<ReadinessResult>;
readonly registerRoutes?: (app: FastifyInstance) => void | Promise<void>;
readonly logger?: false | { readonly stream?: Writable };
}
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');
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,
};
const app = Fastify(fastifyOptions);
const readiness = options.readiness ?? (() => ({ ready: true, checks: { bootstrap: 'ready' } }));
app.addHook('onRequest', async (request, reply) => {
reply.header('X-Request-Id', request.id);
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,
),
);
}
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;
}