From 268ee6b921ee9cab9f5995b6c5144b3d903ed02a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 14 Mar 2022 16:57:43 -0400 Subject: [PATCH] expose `event.clientAddress` (#4289) * expose event.clientAddress * Fix check error * implement for dev and preview * implement for Netlify * implement cloudflare and vercel * throw error if adapter does not specify getClientAddress * update adapter-cloudflare-workers * button up types * add getClientAddress to adapter-node behind trustProxy option * update docs * changesets * docs * set address header explicitly * lint * error on misconfigured header Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> --- .changeset/bright-taxis-remain.md | 9 ++ .changeset/sour-hounds-punch.md | 5 + .changeset/wild-snails-wait.md | 5 + documentation/docs/09-adapters.md | 4 +- .../adapter-cloudflare-workers/files/entry.js | 6 +- packages/adapter-cloudflare/files/worker.js | 7 +- packages/adapter-netlify/src/handler.js | 7 +- packages/adapter-node/README.md | 30 ++++- packages/adapter-node/index.d.ts | 2 + packages/adapter-node/index.js | 8 +- packages/adapter-node/src/handler.d.ts | 2 + packages/adapter-node/src/handler.js | 37 +++++- packages/adapter-vercel/files/entry.js | 10 +- packages/kit/src/core/build/build_server.js | 2 + .../kit/src/core/build/prerender/prerender.js | 7 ++ packages/kit/src/core/dev/plugin.js | 116 ++++++++++-------- packages/kit/src/core/preview/index.js | 11 +- packages/kit/src/runtime/server/index.js | 25 +++- .../kit/src/runtime/server/page/load_node.js | 1 + packages/kit/types/index.d.ts | 2 +- packages/kit/types/internal.d.ts | 7 +- packages/kit/types/private.d.ts | 8 +- 22 files changed, 234 insertions(+), 77 deletions(-) create mode 100644 .changeset/bright-taxis-remain.md create mode 100644 .changeset/sour-hounds-punch.md create mode 100644 .changeset/wild-snails-wait.md diff --git a/.changeset/bright-taxis-remain.md b/.changeset/bright-taxis-remain.md new file mode 100644 index 000000000000..d34996da54d6 --- /dev/null +++ b/.changeset/bright-taxis-remain.md @@ -0,0 +1,9 @@ +--- +'@sveltejs/adapter-cloudflare': patch +'@sveltejs/adapter-cloudflare-workers': patch +'@sveltejs/adapter-netlify': patch +'@sveltejs/adapter-node': patch +'@sveltejs/adapter-vercel': patch +--- + +Provide getClientAddress function diff --git a/.changeset/sour-hounds-punch.md b/.changeset/sour-hounds-punch.md new file mode 100644 index 000000000000..1b0cdc99ff1e --- /dev/null +++ b/.changeset/sour-hounds-punch.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[breaking] require adapters to supply a getClientAddress function diff --git a/.changeset/wild-snails-wait.md b/.changeset/wild-snails-wait.md new file mode 100644 index 000000000000..4bbcab0438ae --- /dev/null +++ b/.changeset/wild-snails-wait.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +expose client IP address as event.clientAddress diff --git a/documentation/docs/09-adapters.md b/documentation/docs/09-adapters.md index 554f4195832f..1b18bdadbd6c 100644 --- a/documentation/docs/09-adapters.md +++ b/documentation/docs/09-adapters.md @@ -103,9 +103,9 @@ Within the `adapt` method, there are a number of things that an adapter should d - Clear out the build directory - Write SvelteKit output with `builder.writeClient`, `builder.writePrerendered`, `builder.writeServer`, and `builder.writeStatic` - Output code that: - - Imports `App` from `${builder.getServerDirectory()}/app.js` + - Imports `Server` from `${builder.getServerDirectory()}/index.js` - Instantiates the app with a manifest generated with `builder.generateManifest({ relativePath })` - - Listens for requests from the platform, converts them to a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) if necessary, calls the `render` function to generate a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) and responds with it + - Listens for requests from the platform, converts them to a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) if necessary, calls the `server.respond(request, { getClientAddress })` function to generate a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) and responds with it - expose any platform-specific information to SvelteKit via the `platform` option passed to `server.respond` - Globally shims `fetch` to work on the target platform, if necessary. SvelteKit provides a `@sveltejs/kit/install-fetch` helper for platforms that can use `node-fetch` - Bundle the output to avoid needing to install dependencies on the target platform, if necessary diff --git a/packages/adapter-cloudflare-workers/files/entry.js b/packages/adapter-cloudflare-workers/files/entry.js index 633e28eab676..9c778e8c109a 100644 --- a/packages/adapter-cloudflare-workers/files/entry.js +++ b/packages/adapter-cloudflare-workers/files/entry.js @@ -50,7 +50,11 @@ async function handle(event) { // dynamically-generated pages try { - return await server.respond(request); + return await server.respond(request, { + getClientAddress() { + return request.headers.get('cf-connecting-ip'); + } + }); } catch (e) { return new Response('Error rendering route:' + (e.message || e.toString()), { status: 500 }); } diff --git a/packages/adapter-cloudflare/files/worker.js b/packages/adapter-cloudflare/files/worker.js index d41d6c27c269..caa76b852a13 100644 --- a/packages/adapter-cloudflare/files/worker.js +++ b/packages/adapter-cloudflare/files/worker.js @@ -50,7 +50,12 @@ export default { // dynamically-generated pages try { - return await server.respond(req, { platform: { env, context } }); + return await server.respond(req, { + platform: { env, context }, + getClientAddress() { + return req.headers.get('cf-connecting-ip'); + } + }); } catch (e) { return new Response('Error rendering route: ' + (e.message || e.toString()), { status: 500 }); } diff --git a/packages/adapter-netlify/src/handler.js b/packages/adapter-netlify/src/handler.js index 160706c1be2e..7d0c0b0135e4 100644 --- a/packages/adapter-netlify/src/handler.js +++ b/packages/adapter-netlify/src/handler.js @@ -10,7 +10,12 @@ export function init(manifest) { const server = new Server(manifest); return async (event, context) => { - const rendered = await server.respond(to_request(event), { platform: { context } }); + const rendered = await server.respond(to_request(event), { + platform: { context }, + getClientAddress() { + return event.headers['x-nf-client-connection-ip']; + } + }); const partial_response = { statusCode: rendered.status, diff --git a/packages/adapter-node/README.md b/packages/adapter-node/README.md index f0f98179ebc6..e301cbe7fb58 100644 --- a/packages/adapter-node/README.md +++ b/packages/adapter-node/README.md @@ -25,7 +25,8 @@ export default { protocol: 'PROTOCOL_HEADER', host: 'HOST_HEADER' } - } + }, + xForwardedForIndex: -1 }) } }; @@ -63,6 +64,14 @@ PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build > [`x-forwarded-proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) and [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host) are de facto standard headers that forward the original protocol and host if you're using a reverse proxy (think load balancers and CDNs). You should only set these variables if you trust the reverse proxy. +The [RequestEvent](https://kit.svelte.dev/docs/types#additional-types-requestevent) object passed to hooks and endpoints includes an `event.clientAddress` property representing the client's IP address. By default this is the connecting `remoteAddress`. If your server is behind one or more proxies (such as a load balancer), this value will contain the innermost proxy's IP address rather than the client's, so we need to specify an `ADDRESS_HEADER` to read the address from: + +``` +ADDRESS_HEADER=True-Client-IP node build +``` + +> Headers can easily be spoofed. As with `PROTOCOL_HEADER` and `HOST_HEADER`, you should [know what you're doing](https://adam-p.ca/blog/2022/03/x-forwarded-for/) before setting these. + All of these environment variables can be changed, if necessary, using the `env` option: ```js @@ -71,6 +80,7 @@ env: { port: 'MY_PORT_VARIABLE', origin: 'MY_ORIGINURL', headers: { + address: 'MY_ADDRESS_HEADER', protocol: 'MY_PROTOCOL_HEADER', host: 'MY_HOST_HEADER' } @@ -84,6 +94,24 @@ MY_ORIGINURL=https://my.site \ node build ``` +### xForwardedForIndex + +If the `ADDRESS_HEADER` is `X-Forwarded-For`, the header value will contain a comma-separated list of IP addresses. For example, if there are three proxies between your server and the client, proxy 3 will forward the addresses of the client and the first two proxies: + +``` +, , +``` + +To get the client address we could use `xForwardedFor: 0` or `xForwardedFor: -3`, which counts back from the number of addresses. + +**X-Forwarded-For is [trivial to spoof](https://adam-p.ca/blog/2022/03/x-forwarded-for/), howevever**: + +``` +, , , +``` + +For that reason you should always use a negative number (depending on the number of proxies) if you need to trust `event.clientAddress`. In the above example, `0` would yield the spoofed address while `-3` would continue to work. + ## Custom server The adapter creates two files in your build directory — `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port. diff --git a/packages/adapter-node/index.d.ts b/packages/adapter-node/index.d.ts index 38ca13c5c8f0..4422fade6753 100644 --- a/packages/adapter-node/index.d.ts +++ b/packages/adapter-node/index.d.ts @@ -15,10 +15,12 @@ interface AdapterOptions { port?: string; origin?: string; headers?: { + address?: string; protocol?: string; host?: string; }; }; + xForwardedForIndex?: number; } declare function plugin(options?: AdapterOptions): Adapter; diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 01e3d2b458eb..3ed23d87576f 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -19,10 +19,12 @@ export default function ({ port: port_env = 'PORT', origin: origin_env = 'ORIGIN', headers: { + address: address_header_env = 'ADDRESS_HEADER', protocol: protocol_header_env = 'PROTOCOL_HEADER', host: host_header_env = 'HOST_HEADER' } = {} - } = {} + } = {}, + xForwardedForIndex = -1 } = {}) { return { name: '@sveltejs/adapter-node', @@ -52,7 +54,9 @@ export default function ({ PORT_ENV: JSON.stringify(port_env), ORIGIN: origin_env ? `process.env[${JSON.stringify(origin_env)}]` : 'undefined', PROTOCOL_HEADER: JSON.stringify(protocol_header_env), - HOST_HEADER: JSON.stringify(host_header_env) + HOST_HEADER: JSON.stringify(host_header_env), + ADDRESS_HEADER: JSON.stringify(address_header_env), + X_FORWARDED_FOR_INDEX: JSON.stringify(xForwardedForIndex) } }); diff --git a/packages/adapter-node/src/handler.d.ts b/packages/adapter-node/src/handler.d.ts index dc1f50b46033..5c89cb6732f1 100644 --- a/packages/adapter-node/src/handler.d.ts +++ b/packages/adapter-node/src/handler.d.ts @@ -2,8 +2,10 @@ import type { Handle } from '@sveltejs/kit'; declare global { const ORIGIN: string; + const ADDRESS_HEADER: string; const HOST_HEADER: string; const PROTOCOL_HEADER: string; + const X_FORWARDED_FOR_INDEX: number; } export const handler: Handle; diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index f3b9662dddd9..b96b98a2cf78 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -7,10 +7,12 @@ import { getRequest, setResponse } from '@sveltejs/kit/node'; import { Server } from 'SERVER'; import { manifest } from 'MANIFEST'; -/* global ORIGIN, PROTOCOL_HEADER, HOST_HEADER */ +/* global ORIGIN, ADDRESS_HEADER, PROTOCOL_HEADER, HOST_HEADER, X_FORWARDED_FOR_INDEX */ const server = new Server(manifest); const origin = ORIGIN; + +const address_header = ADDRESS_HEADER && (process.env[ADDRESS_HEADER] || '').toLowerCase(); const protocol_header = PROTOCOL_HEADER && process.env[PROTOCOL_HEADER]; const host_header = (HOST_HEADER && process.env[HOST_HEADER]) || 'host'; @@ -45,7 +47,38 @@ const ssr = async (req, res) => { return res.end(err.reason || 'Invalid request body'); } - setResponse(res, await server.respond(request)); + if (address_header && !(address_header in req.headers)) { + throw new Error( + `Address header was specified with ${ADDRESS_HEADER}=${process.env[ADDRESS_HEADER]} but is absent from request` + ); + } + + setResponse( + res, + await server.respond(request, { + getClientAddress: () => { + if (address_header) { + const value = /** @type {string} */ (req.headers[address_header]) || ''; + + if (address_header === 'x-forwarded-for') { + const addresses = value.split(','); + return addresses[(addresses.length + X_FORWARDED_FOR_INDEX) % addresses.length].trim(); + } + + return value; + } + + return ( + req.connection?.remoteAddress || + // @ts-expect-error + req.connection?.socket?.remoteAddress || + req.socket?.remoteAddress || + // @ts-expect-error + req.info?.remoteAddress + ); + } + }) + ); }; /** @param {import('polka').Middleware[]} handlers */ diff --git a/packages/adapter-vercel/files/entry.js b/packages/adapter-vercel/files/entry.js index 56f409c1a6fa..dcbd3ebcb6bb 100644 --- a/packages/adapter-vercel/files/entry.js +++ b/packages/adapter-vercel/files/entry.js @@ -10,6 +10,7 @@ const server = new Server(manifest); * @param {import('http').ServerResponse} res */ export default async (req, res) => { + /** @type {Request} */ let request; try { @@ -19,5 +20,12 @@ export default async (req, res) => { return res.end(err.reason || 'Invalid request body'); } - setResponse(res, await server.respond(request)); + setResponse( + res, + await server.respond(request, { + getClientAddress() { + return request.headers.get('x-forwarded-for'); + } + }) + ); }; diff --git a/packages/kit/src/core/build/build_server.js b/packages/kit/src/core/build/build_server.js index 57486a518016..4828d1934261 100644 --- a/packages/kit/src/core/build/build_server.js +++ b/packages/kit/src/core/build/build_server.js @@ -229,6 +229,8 @@ export async function build_server( print_config_conflicts(conflicts, 'kit.vite.', 'build_server'); + process.env.VITE_SVELTEKIT_ADAPTER_NAME = config.kit.adapter?.name; + const { chunks } = await create_build(merged_config); /** @type {import('vite').Manifest} */ diff --git a/packages/kit/src/core/build/prerender/prerender.js b/packages/kit/src/core/build/prerender/prerender.js index c21e4da89635..60ecddc2ceb0 100644 --- a/packages/kit/src/core/build/prerender/prerender.js +++ b/packages/kit/src/core/build/prerender/prerender.js @@ -130,6 +130,7 @@ export async function prerender({ config, entries, files, log }) { const dependencies = new Map(); const response = await server.respond(new Request(`http://sveltekit-prerender${encoded}`), { + getClientAddress, prerender: { default: config.kit.prerender.default, dependencies @@ -268,6 +269,7 @@ export async function prerender({ config, entries, files, log }) { } const rendered = await server.respond(new Request('http://sveltekit-prerender/[fallback]'), { + getClientAddress, prerender: { fallback: true, default: false, @@ -281,3 +283,8 @@ export async function prerender({ config, entries, files, log }) { return prerendered; } + +/** @return {string} */ +function getClientAddress() { + throw new Error('Cannot read clientAddress during prerendering'); +} diff --git a/packages/kit/src/core/dev/plugin.js b/packages/kit/src/core/dev/plugin.js index aee0b340c97a..216eb7f3ed16 100644 --- a/packages/kit/src/core/dev/plugin.js +++ b/packages/kit/src/core/dev/plugin.js @@ -259,62 +259,72 @@ export async function create_plugin(config, cwd) { const template = load_template(cwd, config); - const rendered = await respond(request, { - amp: config.kit.amp, - csp: config.kit.csp, - dev: true, - floc: config.kit.floc, - get_stack: (error) => { - return fix_stack_trace(error); - }, - handle_error: (error, event) => { - hooks.handleError({ - error: new Proxy(error, { - get: (target, property) => { - if (property === 'stack') { - return fix_stack_trace(error); + const rendered = await respond( + request, + { + amp: config.kit.amp, + csp: config.kit.csp, + dev: true, + floc: config.kit.floc, + get_stack: (error) => { + return fix_stack_trace(error); + }, + handle_error: (error, event) => { + hooks.handleError({ + error: new Proxy(error, { + get: (target, property) => { + if (property === 'stack') { + return fix_stack_trace(error); + } + + return Reflect.get(target, property, target); } - - return Reflect.get(target, property, target); + }), + event, + + // TODO remove for 1.0 + // @ts-expect-error + get request() { + throw new Error( + 'request in handleError has been replaced with event. See https://github.com/sveltejs/kit/pull/3384 for details' + ); } - }), - event, - - // TODO remove for 1.0 - // @ts-expect-error - get request() { - throw new Error( - 'request in handleError has been replaced with event. See https://github.com/sveltejs/kit/pull/3384 for details' - ); - } - }); - }, - hooks, - hydrate: config.kit.browser.hydrate, - manifest, - method_override: config.kit.methodOverride, - paths: { - base: config.kit.paths.base, - assets - }, - prefix: '', - prerender: config.kit.prerender.enabled, - read: (file) => fs.readFileSync(path.join(config.kit.files.assets, file)), - root, - router: config.kit.browser.router, - template: ({ head, body, assets, nonce }) => { - return ( - template - .replace(/%svelte\.assets%/g, assets) - .replace(/%svelte\.nonce%/g, nonce) - // head and body must be replaced last, in case someone tries to sneak in %svelte.assets% etc - .replace('%svelte.head%', () => head) - .replace('%svelte.body%', () => body) - ); + }); + }, + hooks, + hydrate: config.kit.browser.hydrate, + manifest, + method_override: config.kit.methodOverride, + paths: { + base: config.kit.paths.base, + assets + }, + prefix: '', + prerender: config.kit.prerender.enabled, + read: (file) => fs.readFileSync(path.join(config.kit.files.assets, file)), + root, + router: config.kit.browser.router, + template: ({ head, body, assets, nonce }) => { + return ( + template + .replace(/%svelte\.assets%/g, assets) + .replace(/%svelte\.nonce%/g, nonce) + // head and body must be replaced last, in case someone tries to sneak in %svelte.assets% etc + .replace('%svelte.head%', () => head) + .replace('%svelte.body%', () => body) + ); + }, + template_contains_nonce: template.includes('%svelte.nonce%'), + trailing_slash: config.kit.trailingSlash }, - template_contains_nonce: template.includes('%svelte.nonce%'), - trailing_slash: config.kit.trailingSlash - }); + { + getClientAddress: () => { + const { remoteAddress } = req.socket; + if (remoteAddress) return remoteAddress; + throw new Error('Could not determine clientAddress'); + } + } + ); if (rendered) { setResponse(res, rendered); diff --git a/packages/kit/src/core/preview/index.js b/packages/kit/src/core/preview/index.js index 1340358b8705..dc53138c0bef 100644 --- a/packages/kit/src/core/preview/index.js +++ b/packages/kit/src/core/preview/index.js @@ -137,7 +137,16 @@ export async function preview({ port, host, config, https: use_https = false }) return res.end(err.reason || 'Invalid request body'); } - setResponse(res, await server.respond(request)); + setResponse( + res, + await server.respond(request, { + getClientAddress: () => { + const { remoteAddress } = req.socket; + if (remoteAddress) return remoteAddress; + throw new Error('Could not determine clientAddress'); + } + }) + ); } ]); diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index bb383f85fd82..a571d1f1ee1b 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -12,7 +12,7 @@ const DATA_SUFFIX = '/__data.json'; const default_transform = ({ html }) => html; /** @type {import('types').Respond} */ -export async function respond(request, options, state = {}) { +export async function respond(request, options, state) { const url = new URL(request.url); const normalized = normalize_path(url.pathname, options.trailing_slash); @@ -53,11 +53,26 @@ export async function respond(request, options, state = {}) { /** @type {import('types').RequestEvent} */ const event = { - request, - url, - params: {}, + get clientAddress() { + if (!state.getClientAddress) { + throw new Error( + `${ + import.meta.env.VITE_SVELTEKIT_ADAPTER_NAME + } does not specify getClientAddress. Please raise an issue` + ); + } + + Object.defineProperty(event, 'clientAddress', { + value: state.getClientAddress() + }); + + return event.clientAddress; + }, locals: {}, - platform: state.platform + params: {}, + platform: state.platform, + request, + url }; // TODO remove this for 1.0 diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js index 296128a6eecd..ee01caf65057 100644 --- a/packages/kit/src/runtime/server/page/load_node.js +++ b/packages/kit/src/runtime/server/page/load_node.js @@ -190,6 +190,7 @@ export async function load_node({ response = await respond(new Request(new URL(requested, event.url).href, opts), options, { fetched: requested, + getClientAddress: state.getClientAddress, initiator: route, prerender: state.prerender }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index fbae9a124e83..abea3ab6f504 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -235,7 +235,7 @@ export type RequestHandlerOutput = MaybePromise< export class Server { constructor(manifest: SSRManifest); - respond(request: Request, options?: RequestOptions): Promise; + respond(request: Request, options: RequestOptions): Promise; } export interface SSRManifest { diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 85f5bc04c2f1..ccff7b02ce11 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -92,7 +92,7 @@ export interface Hooks { export class InternalServer extends Server { respond( request: Request, - options?: RequestOptions & { + options: RequestOptions & { prerender?: PrerenderOptions; } ): Promise; @@ -164,7 +164,7 @@ export type RecursiveRequired = { export type RequiredResolveOptions = Required; export interface Respond { - (request: Request, options: SSROptions, state?: SSRState): Promise; + (request: Request, options: SSROptions, state: SSRState): Promise; } export type RouteData = PageData | EndpointData; @@ -302,11 +302,12 @@ export interface SSRPagePart { export type SSRRoute = SSREndpoint | SSRPage; export interface SSRState { + fallback?: string; fetched?: string; + getClientAddress: () => string; initiator?: SSRPage | null; platform?: any; prerender?: PrerenderOptions; - fallback?: string; } export type StrictBody = string | Uint8Array; diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts index b8c7f9660f14..46a33cc672be 100644 --- a/packages/kit/types/private.d.ts +++ b/packages/kit/types/private.d.ts @@ -234,14 +234,16 @@ export interface PrerenderErrorHandler { export type PrerenderOnErrorValue = 'fail' | 'continue' | PrerenderErrorHandler; export interface RequestEvent> { - request: Request; - url: URL; - params: Params; + clientAddress: string; locals: App.Locals; + params: Params; platform: Readonly; + request: Request; + url: URL; } export interface RequestOptions { + getClientAddress: () => string; platform?: App.Platform; }