@nestjs/core
Version:
Nest - modern, fast, powerful node.js web framework (@core)
277 lines (276 loc) • 11.3 kB
JavaScript
import { ForbiddenException, RequestMethod, } from '@nestjs/common';
import { addLeadingSlash, isFunction, isPlainObject, isString, } from '@nestjs/common/internal';
import { pathToRegexp } from 'path-to-regexp';
import { LegacyRouteConverter } from '../router/legacy-route-converter.js';
import { isRouteExcluded } from '../router/utils/index.js';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const DEFAULT_PORTS = {
'http:': '80',
'https:': '443',
};
/**
* Decides whether a request may proceed, following Go's
* `net/http.CrossOriginProtection.Check`:
*
* 1. `GET`, `HEAD` and `OPTIONS` are always allowed (they must not change
* state). Methods are compared case-sensitively, as HTTP defines them.
* 2. When `Sec-Fetch-Site` is present, `same-origin` and `none` (a
* user-initiated navigation such as a bookmark) are allowed. Any other
* value (`same-site`, `cross-site`, or anything unexpected) is rejected
* unless the request is exempt.
* 3. Without `Sec-Fetch-Site`, a request without `Origin` is allowed: it comes
* from a non-browser client, or from a browser that sends neither header
* for same-origin requests.
* 4. Otherwise the host of `Origin` is compared with the request authority,
* case-insensitively, and ignoring the default port of the `Origin` scheme
* (`https://a.example` matches `a.example:443`). A match is allowed (the
* scheme is not known, so an HTTP to HTTPS request fails open, as in Go;
* HSTS mitigates that). A mismatch is rejected unless the request is
* exempt.
*
* A request is exempt when it matches an exclusion or when its `Origin` is
* one of the trusted origins.
*/
export function evaluateCrossOriginRequest(request, exemptions = {}) {
if (SAFE_METHODS.has(request.method)) {
return { allowed: true, reason: 'safe-method' };
}
const secFetchSite = request.secFetchSite;
if (secFetchSite) {
if (secFetchSite === 'same-origin' || secFetchSite === 'none') {
return { allowed: true, reason: 'same-origin' };
}
return exemptOr(request, exemptions, 'cross-origin-sec-fetch-site');
}
const origin = request.origin;
if (!origin) {
return { allowed: true, reason: 'no-browser-headers' };
}
if (request.host !== undefined && originMatchesHost(origin, request.host)) {
return { allowed: true, reason: 'origin-matches-host' };
}
return exemptOr(request, exemptions, 'cross-origin-origin-header');
}
function exemptOr(request, exemptions, reason) {
if (exemptions.isExcluded?.()) {
return { allowed: true, reason: 'excluded' };
}
if (request.origin !== undefined &&
exemptions.trustedOrigins?.has(request.origin)) {
return { allowed: true, reason: 'trusted-origin' };
}
return { allowed: false, reason };
}
function originMatchesHost(origin, host) {
// "null" (opaque origins, e.g. sandboxed iframes) and anything that is not
// a URL never match a host.
let url;
try {
url = new URL(origin);
}
catch {
return false;
}
if (!url.host) {
return false;
}
// The URL parser lower-cases the host and drops the default port.
const authority = host.toLowerCase();
if (url.host === authority) {
return true;
}
const defaultPort = DEFAULT_PORTS[url.protocol];
return (!url.port &&
defaultPort !== undefined &&
`${url.hostname}:${defaultPort}` === authority);
}
/**
* Validates a trusted origin and returns it in the form browsers serialize
* the `Origin` header (lower-case scheme and host, no default port). Same
* rules as Go's `AddTrustedOrigin`: a scheme and a host are required; a path
* (even `/`), query string, fragment or credentials are not allowed.
* Wildcards are rejected too, as origins are matched exactly.
*/
export function normalizeTrustedOrigin(origin) {
const fail = (reason) => {
throw new Error(`Invalid trusted origin "${origin}": ${reason}.`);
};
if (!isString(origin) || origin.length === 0) {
return fail('expected a non-empty string');
}
if (!/^[a-z][a-z0-9+.-]*:\/\/[^/?#\\]+$/i.test(origin)) {
return fail('expected "scheme://host[:port]", without a path, query string or ' +
'fragment');
}
let url;
try {
url = new URL(origin);
}
catch {
return fail('expected "scheme://host[:port]"');
}
if (!url.host) {
fail('a host is required');
}
if (url.username || url.password) {
fail('credentials are not allowed');
}
if (url.host.includes('*')) {
fail('wildcards are not supported, list each origin');
}
return `${url.protocol}//${url.host}`;
}
const DENIED_MESSAGES = {
'cross-origin-sec-fetch-site': 'Cross-origin request detected from Sec-Fetch-Site header',
'cross-origin-origin-header': 'Cross-origin request detected, and/or browser is out of date: ' +
'Sec-Fetch-Site is missing, and Origin does not match Host',
};
/**
* Request paths an exclusion never applies to, because a router or a proxy
* may resolve them to a route the exclusion was not meant for: `#` and `;`
* (path delimiters for some routers), backslashes, empty segments, dot
* segments, and percent-encoded `.`, `/` and `\`. Such requests are checked
* like any other (fails closed).
*/
const NON_CANONICAL_PATH = /[#;\\]|\/\/|%2e|%2f|%5c|\/\.\.?(?:\/|$)/i;
/**
* Binds {@link evaluateCrossOriginRequest} to an HTTP adapter: reads the
* request through the adapter accessors and turns a rejection into a
* `ForbiddenException`, so exception filters shape the `403` response.
*
* Used by `app.enableCsrfProtection()`.
*/
export class CrossOriginProtection {
httpAdapter;
trustedOrigins;
excludedRoutes = [];
excludePredicate;
resolvedExclusions = [];
/**
* Validates the options, so that a bad configuration fails as soon as
* `app.enableCsrfProtection()` is called.
*/
constructor(httpAdapter, options = {}) {
this.httpAdapter = httpAdapter;
if (!isPlainObject(options)) {
throw new Error('Invalid CSRF protection options: expected an options object.');
}
for (const key of Object.keys(options)) {
if (key !== 'trustedOrigins' && key !== 'exclude') {
throw new Error(`Invalid CSRF protection options: unknown option "${key}" ` +
'(expected "trustedOrigins", "exclude").');
}
}
const { trustedOrigins = [] } = options;
if (!Array.isArray(trustedOrigins)) {
throw new Error('Invalid CSRF protection "trustedOrigins" option: expected a list ' +
'of origins.');
}
this.trustedOrigins = new Set(trustedOrigins.map(normalizeTrustedOrigin));
const { exclude } = options;
if (isFunction(exclude)) {
this.excludePredicate = exclude;
}
else if (Array.isArray(exclude)) {
this.excludedRoutes = exclude.map(toRouteInfo);
for (const route of this.excludedRoutes) {
compileExclusion(route.path, route.method, false);
}
}
else if (exclude !== undefined) {
throw new Error('Invalid CSRF protection "exclude" option: expected a list of ' +
'routes or a function.');
}
}
/**
* Resolves the excluded routes to the paths they are served at, like
* `MiddlewareConsumer.exclude()` does: with the global prefix (unless the
* route is excluded from it) and, with URI versioning, the version segment
* of `RouteInfo.version`. Called by `app.init()`, once the global prefix
* and versioning are final; until then nothing is excluded.
*/
resolveExclusions(pathExtractor) {
this.resolvedExclusions = this.excludedRoutes.flatMap(route => pathExtractor
.extractPathFrom(route)
.map(path => compileExclusion(path, route.method, true)));
}
/**
* Returns a `ForbiddenException` when the request must be rejected, and
* `undefined` otherwise.
*/
check(request) {
const headers = (request.headers ?? {});
const decision = evaluateCrossOriginRequest({
method: this.httpAdapter.getRequestMethod(request),
secFetchSite: readHeader(headers, 'sec-fetch-site'),
origin: readHeader(headers, 'origin'),
// On HTTP/2, ":authority" identifies the target and takes precedence
// over a "Host" header (RFC 9113, section 8.3.1).
host: readHeader(headers, ':authority') ?? readHeader(headers, 'host'),
}, {
trustedOrigins: this.trustedOrigins,
isExcluded: () => this.isExcluded(request),
});
if (decision.allowed) {
return undefined;
}
return new ForbiddenException(DENIED_MESSAGES[decision.reason]);
}
isExcluded(request) {
if (this.excludePredicate) {
return !!this.excludePredicate(request);
}
if (this.resolvedExclusions.length === 0) {
return false;
}
const url = this.httpAdapter.getRequestUrl(request) ?? '';
const queryIndex = url.indexOf('?');
const pathname = queryIndex >= 0 ? url.slice(0, queryIndex) : url;
if (!pathname.startsWith('/') || NON_CANONICAL_PATH.test(pathname)) {
return false;
}
const method = this.httpAdapter.getRequestMethod(request);
return isRouteExcluded(this.resolvedExclusions, pathname, RequestMethod[method]);
}
}
function toRouteInfo(route) {
if (isString(route)) {
return { path: addLeadingSlash(route), method: RequestMethod.ALL };
}
if (!route ||
!isString(route.path) ||
typeof route.method !== 'number' ||
RequestMethod[route.method] === undefined) {
throw new Error(`Invalid CSRF protection exclusion ${JSON.stringify(route)}: expected ` +
'a path, or a { path, method } object where method is a ' +
'RequestMethod.');
}
return { ...route, path: addLeadingSlash(route.path) };
}
/**
* Compiles an exclusion like `MiddlewareConsumer.exclude()` does, except that
* the match is exact: case-sensitive (Fastify routes case-sensitively, so
* `/WEBHOOKS` may reach another route) and without an optional trailing
* slash (Fastify routes `/webhooks/` to `/webhooks/:id` with an empty `id`).
*/
function compileExclusion(path, requestMethod, logs) {
const converted = LegacyRouteConverter.tryConvert(path, { logs });
const normalized = converted.length > 1 ? converted.replace(/\/+$/, '') : converted;
return {
path: normalized,
requestMethod,
pathRegex: pathToRegexp(addLeadingSlash(normalized) || '/', {
sensitive: true,
trailing: false,
}).regexp,
};
}
function readHeader(headers, name) {
const value = headers[name];
if (value === undefined) {
return undefined;
}
// Repeated headers are joined, so a request with conflicting values never
// equals "same-origin" or a trusted origin (fails closed).
return Array.isArray(value) ? value.join(', ') : value;
}