UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

405 lines (404 loc) • 16.9 kB
import fs from "node:fs"; import { Http2ServerResponse } from "node:http2"; import { clientAddressSymbol, nodeRequestAbortControllerCleanupSymbol } from "../constants.js"; import { deserializeManifest } from "./manifest.js"; import { createOutgoingHttpHeaders } from "./createOutgoingHttpHeaders.js"; import { App } from "./app.js"; import { getFirstForwardedValue, validateForwardedHeaders, validateHost } from "./validate-headers.js"; /** * Converts a NodeJS IncomingMessage into a web standard Request. * ```js * import { createApp } from 'astro/app/entrypoint'; * import { createRequest } from 'astro/app/node'; * import { createServer } from 'node:http'; * * const app = createApp(); * * const server = createServer(async (req, res) => { * const request = createRequest(req); * const response = await app.render(request); * }) * ``` */ /** * Internal version of `createRequest` that skips X-Forwarded-* header * validation. Forwarded headers are resolved later inside `FetchState`, * so doing it here too would duplicate work on every request. * * Use this for all internal call sites that go through `app.render()` * (which creates a `FetchState`). The public `createRequest` keeps the * forwarded header logic for external adapters that may not use * `FetchState`. */ export function createRequestFromNodeRequest(req, { skipBody = false, allowedDomains = [], bodySizeLimit, port: serverPort } = {}) { const controller = new AbortController(); const isEncrypted = "encrypted" in req.socket && req.socket.encrypted; const protocol = isEncrypted ? "https" : "http"; const untrustedHostname = typeof req.headers.host === "string" ? req.headers.host : typeof req.headers[":authority"] === "string" ? req.headers[":authority"] : undefined; // Forwarded headers are applied later by FetchState, but the forwarded protocol is // needed here to validate Host against protocol-specific allowedDomains patterns. const validatedForwardedHeaders = validateForwardedHeaders(getFirstForwardedValue(req.headers["x-forwarded-proto"]), getFirstForwardedValue(req.headers["x-forwarded-host"]), getFirstForwardedValue(req.headers["x-forwarded-port"]), allowedDomains); const validatedHostname = validateHost(untrustedHostname, validatedForwardedHeaders.protocol ?? protocol, allowedDomains); // Only a Host matching allowedDomains may control Request.url. Otherwise, fall back // to an origin controlled by the server, matching createRequest's security posture. const fallbackHostname = serverPort ? `localhost:${serverPort}` : "localhost"; const hostname = validatedHostname ?? fallbackHostname; const url = buildRequestUrl(protocol, hostname, req.url, serverPort); const options = { method: req.method || "GET", headers: makeRequestHeaders(req), signal: controller.signal }; const bodyAllowed = options.method !== "HEAD" && options.method !== "GET" && skipBody === false; if (bodyAllowed) { Object.assign(options, makeRequestBody(req, bodySizeLimit)); } const request = new Request(url, options); wireAbortController(req, controller); // Resolve client address. Trust X-Forwarded-For only when the Host // header is validated against allowedDomains (same rule as createRequest). const hostValidated = validatedHostname !== undefined || validatedForwardedHeaders.host !== undefined; const forwardedClientIp = hostValidated ? getFirstForwardedValue(req.headers["x-forwarded-for"]) : undefined; const clientIp = forwardedClientIp || req.socket?.remoteAddress; if (clientIp) { Reflect.set(request, clientAddressSymbol, clientIp); } return request; } export function createRequest(req, { skipBody = false, allowedDomains = [], bodySizeLimit, port: serverPort } = {}) { const controller = new AbortController(); const isEncrypted = "encrypted" in req.socket && req.socket.encrypted; const providedProtocol = isEncrypted ? "https" : "http"; const untrustedHostname = req.headers.host ?? req.headers[":authority"]; // Validate forwarded headers // NOTE: Header values may have commas/spaces from proxy chains, extract first value const validated = validateForwardedHeaders(getFirstForwardedValue(req.headers["x-forwarded-proto"]), getFirstForwardedValue(req.headers["x-forwarded-host"]), getFirstForwardedValue(req.headers["x-forwarded-port"]), allowedDomains); const protocol = validated.protocol ?? providedProtocol; // validated.host is already validated against allowedDomains // For the Host header, we also need to validate against allowedDomains to prevent SSRF // The Host header is only trusted if allowedDomains is configured AND the host matches // Otherwise, fall back to 'localhost' to prevent SSRF attacks const validatedHostname = validateHost(typeof untrustedHostname === "string" ? untrustedHostname : undefined, protocol, allowedDomains); const hostname = validated.host ?? validatedHostname ?? "localhost"; // Use the validated forwarded port if available. When falling back to 'localhost' // (no validated host), use the actual server listening port so that the constructed // URL origin includes it (e.g., http://localhost:4321 instead of http://localhost). // This ensures the CSRF origin comparison uses the correct port without trusting // any value from the request headers. const port = validated.port ?? (!validated.host && !validatedHostname && serverPort ? String(serverPort) : undefined); const url = buildRequestUrl(protocol, getHostnamePort(hostname, port), req.url, serverPort); const options = { method: req.method || "GET", headers: makeRequestHeaders(req), signal: controller.signal }; const bodyAllowed = options.method !== "HEAD" && options.method !== "GET" && skipBody === false; if (bodyAllowed) { Object.assign(options, makeRequestBody(req, bodySizeLimit)); } const request = new Request(url, options); wireAbortController(req, controller); // Get the IP of end client behind the proxy. // Only trust X-Forwarded-For when the request's host was validated against allowedDomains, // meaning it arrived through a trusted proxy. Without this check, any client can spoof // their IP via this header. // @example "1.1.1.1,8.8.8.8" => "1.1.1.1" const hostValidated = validated.host !== undefined || validatedHostname !== undefined; const forwardedClientIp = hostValidated ? getFirstForwardedValue(req.headers["x-forwarded-for"]) : undefined; const clientIp = forwardedClientIp || req.socket?.remoteAddress; if (clientIp) { Reflect.set(request, clientAddressSymbol, clientIp); } return request; } /** * Wires an AbortController to the underlying socket so the request * signal is aborted when the connection closes. Shared by both * `createRequest` and `createRequestFromNodeRequest`. */ function wireAbortController(req, controller) { const socket = getRequestSocket(req); if (socket && typeof socket.on === "function") { const existingCleanup = getAbortControllerCleanup(req); if (existingCleanup) { existingCleanup(); } let cleanedUp = false; const removeSocketListener = () => { if (typeof socket.off === "function") { socket.off("close", onSocketClose); } else if (typeof socket.removeListener === "function") { socket.removeListener("close", onSocketClose); } }; const cleanup = () => { if (cleanedUp) return; cleanedUp = true; removeSocketListener(); controller.signal.removeEventListener("abort", cleanup); Reflect.deleteProperty(req, nodeRequestAbortControllerCleanupSymbol); }; const onSocketClose = () => { cleanup(); if (!controller.signal.aborted) { controller.abort(); } }; socket.on("close", onSocketClose); controller.signal.addEventListener("abort", cleanup, { once: true }); Reflect.set(req, nodeRequestAbortControllerCleanupSymbol, cleanup); if (socket.destroyed) { onSocketClose(); } } } /** * Streams a web-standard Response into a NodeJS Server Response. * ```js * import { createApp } from 'astro/app/entrypoint'; * import { createRequest, writeResponse } from 'astro/app/node'; * import { createServer } from 'node:http'; * * const app = createApp(); * * const server = createServer(async (req, res) => { * const request = createRequest(req); * const response = await app.render(request); * await writeResponse(response, res); * }) * ``` * @param source WhatWG Response * @param destination NodeJS ServerResponse */ export async function writeResponse(source, destination) { const { status, headers, body, statusText } = source; // HTTP/2 doesn't support statusMessage if (!(destination instanceof Http2ServerResponse)) { destination.statusMessage = statusText; } destination.writeHead(status, createOutgoingHttpHeaders(headers)); const cleanupAbortFromDestination = getAbortControllerCleanup(destination.req ?? undefined); if (cleanupAbortFromDestination) { const runCleanup = () => { cleanupAbortFromDestination(); if (typeof destination.off === "function") { destination.off("finish", runCleanup); destination.off("close", runCleanup); } else { destination.removeListener?.("finish", runCleanup); destination.removeListener?.("close", runCleanup); } }; destination.on("finish", runCleanup); destination.on("close", runCleanup); } if (!body) return destination.end(); try { const reader = body.getReader(); destination.on("close", () => { // Cancelling the reader may reject not just because of // an error in the ReadableStream's cancel callback, but // also because of an error anywhere in the stream. reader.cancel().catch((err) => { console.error("There was an uncaught error in the middle of the stream while rendering %s.", destination.req.url, err); }); }); let result = await reader.read(); while (!result.done) { destination.write(result.value); result = await reader.read(); } destination.end(); } catch (err) { destination.write("Internal server error", () => { err instanceof Error ? destination.destroy(err) : destination.destroy(); }); } } /** * @deprecated Use `App` or `createApp()` instead, and use in conjunction with `convertRequest()` * and `writeResponse()` helpers. This will be removed in a future major version. */ export class NodeApp extends App { headersMap = undefined; setHeadersMap(headers) { this.headersMap = headers; } match(req, allowPrerenderedRoutes = false) { if (!(req instanceof Request)) { req = createRequestFromNodeRequest(req, { skipBody: true }); } return super.match(req, allowPrerenderedRoutes); } render(request, options) { if (!(request instanceof Request)) { request = createRequestFromNodeRequest(request); } return super.render(request, options); } /** * Converts a NodeJS IncomingMessage into a web standard Request. * ```js * import { NodeApp } from 'astro/app/node'; * import { createServer } from 'node:http'; * * const server = createServer(async (req, res) => { * const request = NodeApp.createRequest(req); * const response = await app.render(request); * await NodeApp.writeResponse(response, res); * }) * ``` */ static createRequest = createRequest; /** * Streams a web-standard Response into a NodeJS Server Response. * ```js * import { NodeApp } from 'astro/app/node'; * import { createServer } from 'node:http'; * * const server = createServer(async (req, res) => { * const request = NodeApp.createRequest(req); * const response = await app.render(request); * await NodeApp.writeResponse(response, res); * }) * ``` * @param source WhatWG Response * @param destination NodeJS ServerResponse */ static writeResponse = writeResponse; } function getHostnamePort(hostname, port) { const portInHostname = typeof hostname === "string" && /:\d+$/.test(hostname); const hostnamePort = portInHostname ? hostname : `${hostname}${port ? `:${port}` : ""}`; return hostnamePort; } /** * Builds the request URL from a client-supplied host, which may contain an * unparseable port (e.g. `example.com:65536`). Parsing degrades in steps so * that construction always yields a URL: * * 1. Full URL including the request path. * 2. Origin only, dropping a request path that alone made the URL invalid. * 3. A host the server controls, when the host itself is unparseable — using * the listening port when known so the origin still carries the right port. */ function buildRequestUrl(protocol, hostnamePort, requestPath, serverPort) { const path = requestPath ?? ""; if (URL.canParse(`${protocol}://${hostnamePort}${path}`)) { return new URL(`${protocol}://${hostnamePort}${path}`); } if (URL.canParse(`${protocol}://${hostnamePort}`)) { return new URL(`${protocol}://${hostnamePort}`); } const fallbackHost = serverPort ? `localhost:${serverPort}` : "localhost"; return new URL(`${protocol}://${fallbackHost}`); } function makeRequestHeaders(req) { const headers = new Headers(); for (const [name, value] of Object.entries(req.headers)) { if (value === undefined) { continue; } if (Array.isArray(value)) { for (const item of value) { headers.append(name, item); } } else { headers.append(name, value); } } return headers; } function makeRequestBody(req, bodySizeLimit) { if (req.body !== undefined) { if (typeof req.body === "string" && req.body.length > 0) { return { body: Buffer.from(req.body) }; } // Pass through binary data directly. Buffer, Uint8Array, and other typed arrays // are valid BodyInit values and must not be JSON-stringified. Without this check // they fall into the plain-object branch below because typeof returns 'object'. if (req.body instanceof ArrayBuffer || ArrayBuffer.isView(req.body)) { return { body: req.body }; } if (typeof req.body === "object" && req.body !== null && Object.keys(req.body).length > 0) { return { body: Buffer.from(JSON.stringify(req.body)) }; } // This covers all async iterables including Readable and ReadableStream. if (typeof req.body === "object" && req.body !== null && typeof req.body[Symbol.asyncIterator] !== "undefined") { return asyncIterableToBodyProps(req.body, bodySizeLimit); } } // Return default body. return asyncIterableToBodyProps(req, bodySizeLimit); } function asyncIterableToBodyProps(iterable, bodySizeLimit) { const source = bodySizeLimit != null ? limitAsyncIterable(iterable, bodySizeLimit) : iterable; return { // Node uses undici for the Request implementation. Undici accepts // a non-standard async iterable for the body. // @ts-expect-error body: source, // The duplex property is required when using a ReadableStream or async // iterable for the body. The type definitions do not include the duplex // property because they are not up-to-date. duplex: "half" }; } /** * Wraps an async iterable with a size limit. If the total bytes received * exceed the limit, an error is thrown. */ async function* limitAsyncIterable(iterable, limit) { let received = 0; for await (const chunk of iterable) { const byteLength = chunk instanceof Uint8Array ? chunk.byteLength : typeof chunk === "string" ? Buffer.byteLength(chunk) : 0; received += byteLength; if (received > limit) { throw new Error(`Body size limit exceeded: received more than ${limit} bytes`); } yield chunk; } } /** * Returns the cleanup function for the AbortController and socket listeners created by `createRequest()` * for the NodeJS IncomingMessage. This should only be called directly if the request is not * being handled by Astro, i.e. if not calling `writeResponse()` after `createRequest()`. * ```js * import { createRequest, getAbortControllerCleanup } from 'astro/app/node'; * import { createServer } from 'node:http'; * * const server = createServer(async (req, res) => { * const request = createRequest(req); * const cleanup = getAbortControllerCleanup(req); * if (cleanup) cleanup(); * // can now safely call another handler * }) * ``` */ export function getAbortControllerCleanup(req) { if (!req) return undefined; const cleanup = Reflect.get(req, nodeRequestAbortControllerCleanupSymbol); return typeof cleanup === "function" ? cleanup : undefined; } function getRequestSocket(req) { if (req.socket && typeof req.socket.on === "function") { return req.socket; } const http2Socket = req.stream?.session?.socket; if (http2Socket && typeof http2Socket.on === "function") { return http2Socket; } return undefined; } /** @deprecated This will be removed in a future major version. */ export async function loadManifest(rootFolder) { const manifestFile = new URL("./manifest.json", rootFolder); const rawManifest = await fs.promises.readFile(manifestFile, "utf-8"); const serializedManifest = JSON.parse(rawManifest); return deserializeManifest(serializedManifest); } /** @deprecated This will be removed in a future major version. */ export async function loadApp(rootFolder) { const manifest = await loadManifest(rootFolder); return new NodeApp(manifest); }