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
JavaScript
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);
}