diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 65b59d89e..977d57251 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -10,6 +10,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned - [Configuration](#configuration) - [Development server authentication](#development-server-authentication) +- [Development server requests](#development-server-requests) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -47,6 +48,16 @@ passes it to the dev server via `DD_OAUTH_ACCESS_TOKEN`. When no credentials are configured, backend function execution is unavailable and the dev server tells you to start it with `datadog-apps dev`. +## Development server requests + +The dev server's backend function endpoints (`/__dd/executeAction`, `/__dd/executeActionViaCloud` and `/__dd/debugBundle`) accept a `POST` only when: + +- its `Host` passes Vite's [`server.allowedHosts`](https://vite.dev/config/server-options#server-allowedhosts) check, which Vite skips when `server.https` is set or `server.allowedHosts` is `true`; +- its `Origin`, when sent, names the same host and port as its `Host`; +- its body is sent with `Content-Type: application/json`. + +To call one by hand, send that content type, for example `curl -X POST -H 'Content-Type: application/json' -d '{"functionName":"..."}' http://localhost:5173/__dd/debugBundle`. + ## Package output A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time. diff --git a/packages/plugins/apps/src/vite/dev-server-request-guard.test.ts b/packages/plugins/apps/src/vite/dev-server-request-guard.test.ts new file mode 100644 index 000000000..9146985f3 --- /dev/null +++ b/packages/plugins/apps/src/vite/dev-server-request-guard.test.ts @@ -0,0 +1,394 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { HostCheckConfig } from '@dd/apps-plugin/vite/dev-server-request-guard'; +import { + checkDevServerRequest, + createViteHostCheck, + MISSING_HOST_MESSAGE, +} from '@dd/apps-plugin/vite/dev-server-request-guard'; +import type { IncomingHttpHeaders } from 'http'; + +const PREVIEW_SUFFIX = '.preview.example'; +const PREVIEW_HOST = `app-5173${PREVIEW_SUFFIX}`; + +const withServer = ( + server: HostCheckConfig['server'], + preview: HostCheckConfig['preview'] = {}, +): HostCheckConfig => ({ server, preview }); + +describe('dev-server-request-guard — createViteHostCheck', () => { + const cases: { + description: string; + config: HostCheckConfig; + host: string; + expected: boolean; + }[] = [ + { + description: 'allow localhost with a port', + config: withServer({ allowedHosts: [] }), + host: 'localhost:5173', + expected: true, + }, + { + description: 'allow a name under .localhost', + config: withServer({ allowedHosts: [] }), + host: 'app.localhost:5173', + expected: true, + }, + { + description: 'allow an IPv4 literal', + config: withServer({ allowedHosts: [] }), + host: '127.0.0.1:5173', + expected: true, + }, + { + description: 'allow an IPv6 literal', + config: withServer({ allowedHosts: [] }), + host: '[::1]:5173', + expected: true, + }, + { + description: 'reject an unterminated IPv6 literal', + config: withServer({ allowedHosts: [] }), + host: '[::1', + expected: false, + }, + { + description: 'reject a name the config does not allow', + config: withServer({ allowedHosts: [] }), + host: 'attacker.example:5173', + expected: false, + }, + { + description: "reject a name Vite's file: and -extension: allowance would let through", + config: withServer({ allowedHosts: [] }), + host: 'build-extension:5173', + expected: false, + }, + { + description: 'allow an exact allowedHosts entry', + config: withServer({ allowedHosts: ['dev.example'] }), + host: 'dev.example:5173', + expected: true, + }, + { + description: 'allow a host under a .suffix allowedHosts entry', + config: withServer({ allowedHosts: [PREVIEW_SUFFIX] }), + host: PREVIEW_HOST, + expected: true, + }, + { + description: 'allow the bare name of a .suffix allowedHosts entry', + config: withServer({ allowedHosts: [PREVIEW_SUFFIX] }), + host: PREVIEW_SUFFIX.slice(1), + expected: true, + }, + { + description: 'reject a name that only shares the end of a .suffix entry', + config: withServer({ allowedHosts: [PREVIEW_SUFFIX] }), + host: `not${PREVIEW_SUFFIX.slice(1)}`, + expected: false, + }, + { + description: 'allow the configured server.host', + config: withServer({ allowedHosts: [], host: 'devbox.internal' }), + host: 'devbox.internal:5173', + expected: true, + }, + { + description: 'allow the configured server.hmr.host', + config: withServer({ allowedHosts: [], hmr: { host: 'hmr.internal' } }), + host: 'hmr.internal:5173', + expected: true, + }, + { + description: 'match configured server hosts exactly, never as a .suffix', + config: withServer({ allowedHosts: [], hmr: { host: '.corp.example' } }), + host: 'evil.corp.example:5173', + expected: false, + }, + { + description: 'allow the hostname of server.origin', + config: withServer({ allowedHosts: [], origin: 'https://assets.example:8443' }), + host: 'assets.example', + expected: true, + }, + { + description: 'allow the configured preview.host', + config: withServer({ allowedHosts: [] }, { host: 'preview.internal' }), + host: 'preview.internal:4173', + expected: true, + }, + { + description: 'allow any host when allowedHosts is true', + config: withServer({ allowedHosts: true }), + host: 'attacker.example:5173', + expected: true, + }, + { + description: 'allow any host when the server uses https', + config: withServer({ allowedHosts: [], https: {} }), + host: 'attacker.example:5173', + expected: true, + }, + ]; + + test.each(cases)('Should $description', ({ config, host, expected }) => { + const isHostAllowed = createViteHostCheck(config); + const allowed = isHostAllowed(host); + + expect(allowed).toBe(expected); + }); +}); + +describe('dev-server-request-guard — createViteHostCheck config reads', () => { + test('Should read the config on each request, so a later plugin can still change it', () => { + const config = withServer({ allowedHosts: [] }); + const isHostAllowed = createViteHostCheck(config); + config.server.allowedHosts = ['tunnel.example']; + + const allowed = isHostAllowed('tunnel.example:5173'); + + expect(allowed).toBe(true); + }); +}); + +describe('dev-server-request-guard — createViteHostCheck untyped config', () => { + test('Should skip non-string allowedHosts entries, as Vite does, rather than throw', () => { + const config = withServer({ allowedHosts: [] }); + Reflect.set(config.server, 'allowedHosts', [/\.example$/, 'dev.example']); + const isHostAllowed = createViteHostCheck(config); + const listedAllowed = isHostAllowed('dev.example:5173'); + const unlistedAllowed = isHostAllowed('other.example:5173'); + + expect(listedAllowed).toBe(true); + expect(unlistedAllowed).toBe(false); + }); + + test('Should ignore a null server.hmr from a JS config rather than throw', () => { + const config = withServer({ allowedHosts: [] }); + Reflect.set(config.server, 'hmr', null); + const isHostAllowed = createViteHostCheck(config); + const localAllowed = isHostAllowed('localhost:5173'); + const remoteAllowed = isHostAllowed('dev.example:5173'); + + expect(localAllowed).toBe(true); + expect(remoteAllowed).toBe(false); + }); + + test('Should treat a string allowedHosts from a JS config as no extra hosts', () => { + const config = withServer({ allowedHosts: [] }); + Reflect.set(config.server, 'allowedHosts', 'all'); + const isHostAllowed = createViteHostCheck(config); + const localAllowed = isHostAllowed('localhost:5173'); + const remoteAllowed = isHostAllowed('dev.example:5173'); + + expect(localAllowed).toBe(true); + expect(remoteAllowed).toBe(false); + }); +}); + +describe('dev-server-request-guard — checkDevServerRequest', () => { + const previewConfig = withServer({ allowedHosts: [PREVIEW_SUFFIX] }); + const isHostAllowed = createViteHostCheck(previewConfig); + const json = 'application/json'; + + const cases: { + description: string; + headers: IncomingHttpHeaders; + expectedStatus: number | undefined; + }[] = [ + { + description: 'accept a same-origin JSON request on localhost', + headers: { + host: 'localhost:5173', + origin: 'http://localhost:5173', + 'content-type': json, + }, + expectedStatus: undefined, + }, + { + description: 'accept a JSON content type with a charset parameter, in any case', + headers: { + host: '127.0.0.1:5173', + origin: 'http://127.0.0.1:5173', + 'content-type': 'Application/JSON; charset=utf-8', + }, + expectedStatus: undefined, + }, + { + description: 'accept a request with no Origin, as non-browser clients send', + headers: { host: 'localhost:5173', 'content-type': json }, + expectedStatus: undefined, + }, + { + description: 'accept a preview proxy host, whose https Origin reaches Vite over http', + headers: { + host: PREVIEW_HOST, + origin: `https://${PREVIEW_HOST}`, + 'content-type': json, + }, + expectedStatus: undefined, + }, + { + description: "accept an Origin on its scheme's default port against a Host naming it", + headers: { + host: `${PREVIEW_HOST}:443`, + origin: `https://${PREVIEW_HOST}`, + 'content-type': json, + }, + expectedStatus: undefined, + }, + { + description: 'accept an HTTP/2 request, which carries :authority instead of Host', + headers: { + ':authority': 'localhost:5173', + origin: 'https://localhost:5173', + 'content-type': json, + }, + expectedStatus: undefined, + }, + { + description: 'reject an HTTP/2 request whose :authority Vite would not serve', + headers: { + ':authority': 'attacker.example:5173', + origin: 'https://attacker.example:5173', + 'content-type': json, + }, + expectedStatus: 403, + }, + { + description: 'reject a cross-site Origin', + headers: { + host: 'localhost:5173', + origin: 'https://attacker.example', + 'content-type': json, + }, + expectedStatus: 403, + }, + { + description: 'reject an opaque Origin', + headers: { host: 'localhost:5173', origin: 'null', 'content-type': json }, + expectedStatus: 403, + }, + { + description: 'reject another port on the same host', + headers: { + host: 'localhost:5173', + origin: 'http://localhost:3000', + 'content-type': json, + }, + expectedStatus: 403, + }, + { + description: 'reject a sibling preview proxy host', + headers: { + host: PREVIEW_HOST, + origin: `https://app-5174${PREVIEW_SUFFIX}`, + 'content-type': json, + }, + expectedStatus: 403, + }, + { + description: 'reject a same-origin request under a host Vite would not serve', + headers: { + host: 'attacker.example:5173', + origin: 'http://attacker.example:5173', + 'content-type': json, + }, + expectedStatus: 403, + }, + { + description: 'reject a Host whose userinfo would make it parse as the Origin host', + headers: { + host: 'localhost:5173@attacker.example', + origin: 'http://attacker.example', + 'content-type': json, + }, + expectedStatus: 403, + }, + { + description: 'reject a percent-encoded Host that would decode to the Origin host', + headers: { + host: 'evil%2ecom.localhost:5173', + origin: 'http://evil.com.localhost:5173', + 'content-type': json, + }, + expectedStatus: 403, + }, + { + description: 'reject a missing Host header', + headers: { 'content-type': json }, + expectedStatus: 403, + }, + { + description: 'reject text/plain', + headers: { + host: 'localhost:5173', + origin: 'http://localhost:5173', + 'content-type': 'text/plain', + }, + expectedStatus: 415, + }, + { + description: 'reject a form-encoded body', + headers: { + host: 'localhost:5173', + 'content-type': 'application/x-www-form-urlencoded', + }, + expectedStatus: 415, + }, + { + description: 'reject a media type that only starts with application/json', + headers: { host: 'localhost:5173', 'content-type': 'application/jsonp' }, + expectedStatus: 415, + }, + { + description: 'reject a missing Content-Type', + headers: { host: 'localhost:5173', origin: 'http://localhost:5173' }, + expectedStatus: 415, + }, + ]; + + test.each(cases)('Should $description', ({ headers, expectedStatus }) => { + const rejection = checkDevServerRequest(headers, isHostAllowed); + + expect(rejection?.statusCode).toBe(expectedStatus); + }); + + test('Should name a missing Host rather than suggest an allowedHosts entry', () => { + const rejection = checkDevServerRequest({ 'content-type': json }, isHostAllowed); + + expect(rejection?.message).toBe(MISSING_HOST_MESSAGE); + }); + + test('Should name a bracketed IPv6 host whole in the rejection message', () => { + const rejection = checkDevServerRequest( + { host: '[fe80::zz]:5173', 'content-type': json }, + isHostAllowed, + ); + + expect(rejection?.message).toContain('"[fe80::zz]"'); + }); + + test('Should treat an empty Host header as missing, even under a "." allowedHosts entry', () => { + const dotConfig = withServer({ allowedHosts: ['.'] }); + const allowDot = createViteHostCheck(dotConfig); + const rejection = checkDevServerRequest({ host: '', 'content-type': json }, allowDot); + + expect(rejection?.statusCode).toBe(403); + }); + + test('Should check the Origin against the Host even when Vite skips its host check', () => { + const allowAnyHostConfig = withServer({ allowedHosts: true }); + const allowAnyHost = createViteHostCheck(allowAnyHostConfig); + const rejection = checkDevServerRequest( + { host: 'localhost:5173', origin: 'https://attacker.example', 'content-type': json }, + allowAnyHost, + ); + + expect(rejection?.statusCode).toBe(403); + }); +}); diff --git a/packages/plugins/apps/src/vite/dev-server-request-guard.ts b/packages/plugins/apps/src/vite/dev-server-request-guard.ts new file mode 100644 index 000000000..5de1c7174 --- /dev/null +++ b/packages/plugins/apps/src/vite/dev-server-request-guard.ts @@ -0,0 +1,150 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { IncomingHttpHeaders } from 'http'; +import { isIP } from 'net'; +import type { ResolvedPreviewOptions, ResolvedServerOptions } from 'vite'; + +export type HostCheck = (hostHeader: string) => boolean; + +export interface HostCheckConfig { + server: Pick; + preview: Pick; +} + +export const MISSING_HOST_MESSAGE = 'Blocked request with no Host header.'; + +export interface RequestRejection { + statusCode: 403 | 415; + message: string; +} + +// Matched exactly, as Vite 5 and 6 do; Vite 7+ folds them into `server.allowedHosts`. +function getConfiguredHosts({ server, preview }: HostCheckConfig): string[] { + const hosts: string[] = []; + if (typeof server.host === 'string' && server.host) { + hosts.push(server.host); + } + if (typeof server.hmr === 'object' && server.hmr?.host) { + hosts.push(server.hmr.host); + } + if (typeof preview.host === 'string' && preview.host) { + hosts.push(preview.host); + } + if (server.origin) { + try { + const originUrl = new URL(server.origin); + hosts.push(originUrl.hostname); + } catch { + // Vite ignores an unparseable origin too. + } + } + return hosts; +} + +function getHostname(hostHeader: string): string { + const trimmedHost = hostHeader.trim(); + if (trimmedHost.startsWith('[')) { + const ipv6End = trimmedHost.indexOf(']'); + return ipv6End < 0 ? trimmedHost : trimmedHost.slice(0, ipv6End + 1); + } + const colonIndex = trimmedHost.indexOf(':'); + return colonIndex === -1 ? trimmedHost : trimmedHost.slice(0, colonIndex); +} + +function matchesAllowedHosts( + hostHeader: string, + allowedHosts: readonly unknown[], + configuredHosts: readonly string[], +): boolean { + const hostname = getHostname(hostHeader); + if (hostname.startsWith('[')) { + const ipv6Address = hostname.slice(1, -1); + return hostname.endsWith(']') && isIP(ipv6Address) === 6; + } + if (isIP(hostname) === 4 || hostname === 'localhost' || hostname.endsWith('.localhost')) { + return true; + } + if (configuredHosts.includes(hostname)) { + return true; + } + return allowedHosts.some( + (allowedHost) => + typeof allowedHost === 'string' && + (allowedHost === hostname || + (allowedHost.startsWith('.') && + (allowedHost.slice(1) === hostname || hostname.endsWith(allowedHost)))), + ); +} + +/** + * Vite's dev-server host check, which Vite doesn't export and Vite 5 and 6 run after plugin middleware. + * Unlike Vite, it drops the `file:`/`-extension:` allowance. + */ +export function createViteHostCheck(config: HostCheckConfig): HostCheck { + if (config.server.https) { + return () => true; + } + const configuredHosts = getConfiguredHosts(config); + return (hostHeader) => { + // Only `allowedHosts` is read per request, as Vite 6 does; it isn't an array on Vite + // versions that predate it or in an untyped config. + const { allowedHosts } = config.server; + if (allowedHosts === true) { + return true; + } + const allowedHostList = Array.isArray(allowedHosts) ? allowedHosts : []; + return matchesAllowedHosts(hostHeader, allowedHostList, configuredHosts); + }; +} + +const NON_AUTHORITY_CHARACTERS_RE = /[@/?#%\\]/; + +// The scheme isn't compared: behind a TLS-terminating proxy the Origin is https while the dev server is not. +function isSameOriginAsHost(origin: string, hostHeader: string): boolean { + if (NON_AUTHORITY_CHARACTERS_RE.test(hostHeader)) { + return false; + } + try { + const originUrl = new URL(origin); + return originUrl.host === new URL(`${originUrl.protocol}//${hostHeader}`).host; + } catch { + return false; + } +} + +function isJsonContentType(contentType: string | undefined): boolean { + return contentType?.split(';')[0].trim().toLowerCase() === 'application/json'; +} + +export function checkDevServerRequest( + headers: IncomingHttpHeaders, + isHostAllowed: HostCheck, +): RequestRejection | undefined { + const { origin } = headers; + // HTTP/2 requests carry :authority and no Host. + const authority = headers[':authority']; + const host = headers.host ?? (typeof authority === 'string' ? authority : undefined); + if (!host) { + return { statusCode: 403, message: MISSING_HOST_MESSAGE }; + } + if (!isHostAllowed(host)) { + const hostname = getHostname(host); + const quotedHostname = JSON.stringify(hostname); + return { + statusCode: 403, + message: `Blocked request. This host (${quotedHostname}) is not allowed. To allow it, add it to \`server.allowedHosts\` in vite.config.js.`, + }; + } + if (origin !== undefined && !isSameOriginAsHost(origin, host)) { + return { + statusCode: 403, + message: "Blocked request. Only the dev server's own origin can call this endpoint.", + }; + } + if (!isJsonContentType(headers['content-type'])) { + return { statusCode: 415, message: 'Requests must use Content-Type: application/json.' }; + } + return undefined; +} diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index fc3ba987a..9cc905621 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -11,7 +11,12 @@ import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; import { collectModuleGraphFromServer } from '@dd/apps-plugin/vite/dev-server-module-graph'; -import { createDevServerMiddleware } from '@dd/apps-plugin/vite/dev-server'; +import { createViteHostCheck } from '@dd/apps-plugin/vite/dev-server-request-guard'; +import { + createDevServerMiddleware, + DEBUG_BUNDLE_ROUTE, + EXECUTE_ACTION_ROUTE, +} from '@dd/apps-plugin/vite/dev-server'; import { getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; import { FS_WRITE_BLOCKED_MESSAGE } from '@dd/apps-plugin/vite/network-guard'; import { outputFileSync, rmSync } from '@dd/core/helpers/fs'; @@ -24,6 +29,7 @@ import { getMockLogger, } from '@dd/tests/_jest/helpers/mocks'; import fs from 'fs'; +import http from 'http'; import nock from 'nock'; import os from 'os'; import path from 'path'; @@ -190,6 +196,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { appKey: 'test-app-key', site: 'datadoghq.com', }; + const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, server.ssrLoadModule.bind(server), @@ -201,6 +208,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { FIXTURE_ROOT, getMockLogger(), 'development', + isHostAllowed, ); const req = createMockRequest('/__dd/executeAction', { @@ -233,6 +241,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { appKey: 'test-app-key', site: 'datadoghq.com', }; + const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, server.ssrLoadModule.bind(server), @@ -244,6 +253,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { FIXTURE_ROOT, getMockLogger(), 'development', + isHostAllowed, ); const req = createMockRequest('/__dd/executeAction', { @@ -270,6 +280,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { appKey: 'test-app-key', site: 'datadoghq.com', }; + const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, server.ssrLoadModule.bind(server), @@ -281,6 +292,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { FIXTURE_ROOT, getMockLogger(), 'development', + isHostAllowed, ); const req = createMockRequest('/__dd/executeAction', { @@ -346,6 +358,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { appKey: 'test-app-key', site: 'datadoghq.com', }; + const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, loadModule, @@ -357,6 +370,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { FIXTURE_ROOT, getMockLogger(), 'development', + isHostAllowed, ); const req = createMockRequest('/__dd/executeAction', { @@ -396,6 +410,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { appKey: 'test-app-key', site: 'datadoghq.com', }; + const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, loadModule, @@ -407,6 +422,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { FIXTURE_ROOT, getMockLogger(), 'development', + isHostAllowed, ); // The connection-ID collector is under test here, not the preview-async round trip @@ -455,6 +471,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { appKey: 'test-app-key', site: 'datadoghq.com', }; + const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, loadModule, @@ -466,6 +483,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { FIXTURE_ROOT, getMockLogger(), 'development', + isHostAllowed, ); const apiScope = nock('https://api.datadoghq.com') @@ -960,3 +978,192 @@ describe('Dev Server Middleware — dynamic imports inside a backend function', } }, 30000); }); + +describe('Dev Server Middleware — request origin and host checks over real HTTP', () => { + const VITE_ADDITIONAL_ALLOWED_HOSTS_ENV = '__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS'; + const previewSuffix = '.preview.example'; + const configuredOriginHost = 'origin.internal'; + let server: ViteDevServer; + let port: number; + + function request( + method: string, + urlPath: string, + headers: http.OutgoingHttpHeaders, + body?: string, + ): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, method, path: urlPath, headers }, + (res) => { + let responseBody = ''; + res.on('data', (chunk: Buffer) => { + responseBody += chunk.toString(); + }); + res.on('end', () => + resolve({ status: res.statusCode ?? 0, body: responseBody }), + ); + }, + ); + req.on('error', reject); + req.end(body); + }); + } + + const functionName = encodeQueryName(getRuntimeUsersFunc); + const debugBundleBody = JSON.stringify({ functionName }); + const executeActionBody = JSON.stringify({ functionName, args: [] }); + const unknownFunctionBody = JSON.stringify({ functionName: 'unknown' }); + const postDebugBundle = (headers: http.OutgoingHttpHeaders) => + request('POST', DEBUG_BUNDLE_ROUTE, headers, debugBundleBody); + + beforeAll(async () => { + const context = getContextMock({ buildRoot: FIXTURE_ROOT }); + const appsPlugin: Plugin = { + name: 'dd-apps-test', + ...getVitePlugin({ + bundler: { build }, + context, + options: { include: [], tags: [], longPolling: mockLongPolling }, + }), + }; + + // Hosted preview environments set this so Vite answers to their proxy's host. + const previousAllowedHosts = process.env[VITE_ADDITIONAL_ALLOWED_HOSTS_ENV]; + process.env[VITE_ADDITIONAL_ALLOWED_HOSTS_ENV] = previewSuffix; + try { + server = await createServer({ + configFile: false, + root: FIXTURE_ROOT, + logLevel: 'silent', + server: { + host: '127.0.0.1', + port: 0, + hmr: false, + origin: `http://${configuredOriginHost}`, + }, + plugins: [appsPlugin], + optimizeDeps: { noDiscovery: true }, + }); + } finally { + if (previousAllowedHosts === undefined) { + delete process.env[VITE_ADDITIONAL_ALLOWED_HOSTS_ENV]; + } else { + process.env[VITE_ADDITIONAL_ALLOWED_HOSTS_ENV] = previousAllowedHosts; + } + } + await server.listen(); + const address = server.httpServer?.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the dev server to listen on a TCP port'); + } + port = address.port; + await server.ssrLoadModule(getRuntimeUsersFunc.absolutePath); + nock.enableNetConnect(`127.0.0.1:${port}`); + }, 30000); + + afterAll(async () => { + nock.disableNetConnect(); + await server?.close(); + }); + + test('Should serve a same-origin JSON request on localhost', async () => { + const response = await postDebugBundle({ + host: `localhost:${port}`, + origin: `http://localhost:${port}`, + 'content-type': 'application/json', + }); + + expect(response.status).toBe(200); + }, 30000); + + test('Should serve a request proxied under an allowed preview proxy host', async () => { + const previewHost = `app-${port}${previewSuffix}`; + const response = await postDebugBundle({ + host: previewHost, + origin: `https://${previewHost}`, + 'content-type': 'application/json', + }); + + expect(response.status).toBe(200); + }, 30000); + + test.each([ + { + description: 'a cross-site Origin', + headers: (p: number) => ({ + host: `localhost:${p}`, + origin: 'https://attacker.example', + 'content-type': 'application/json', + }), + expectedStatus: 403, + }, + { + description: 'a text/plain body', + headers: (p: number) => ({ + host: `127.0.0.1:${p}`, + origin: `http://127.0.0.1:${p}`, + 'content-type': 'text/plain', + }), + expectedStatus: 415, + }, + { + description: 'a same-origin request under a host Vite would not serve', + headers: (p: number) => ({ + host: `attacker.example:${p}`, + origin: `http://attacker.example:${p}`, + 'content-type': 'application/json', + }), + expectedStatus: 403, + }, + ])( + `Should reject ${EXECUTE_ACTION_ROUTE} with $expectedStatus for $description`, + async ({ headers, expectedStatus }) => { + const requestHeaders = headers(port); + const response = await request( + 'POST', + EXECUTE_ACTION_ROUTE, + requestHeaders, + executeActionBody, + ); + + const body = JSON.parse(response.body); + + expect(response.status).toBe(expectedStatus); + expect(body.success).toBe(false); + }, + ); + + test.each([ + 'localhost', + '127.0.0.1', + 'app.localhost', + 'attacker.example', + `app-5173${previewSuffix}`, + previewSuffix.slice(1), + `not${previewSuffix.slice(1)}`, + '[::1]', + configuredOriginHost, + `sub.${configuredOriginHost}`, + ])( + "Should accept the host %s exactly when Vite's own host check does", + async (hostname) => { + const host = `${hostname}:${port}`; + const viteResponse = await request('GET', '/@vite/client', { host }); + const ownHeaders = { + host, + origin: `http://${host}`, + 'content-type': 'application/json', + }; + const ownResponse = await request( + 'POST', + DEBUG_BUNDLE_ROUTE, + ownHeaders, + unknownFunctionBody, + ); + + expect(ownResponse.status).toBe(viteResponse.status === 403 ? 403 : 404); + }, + 30000, + ); +}); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 77b62dd55..0acc186cf 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -5,8 +5,11 @@ /* global globalThis */ import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; +import { createViteHostCheck } from '@dd/apps-plugin/vite/dev-server-request-guard'; import { createDevServerMiddleware, + DEBUG_BUNDLE_ROUTE, + DEV_SERVER_ROUTES, formatRef, getRetryDelay, } from '@dd/apps-plugin/vite/dev-server'; @@ -43,6 +46,8 @@ const mockLoadModule = jest.fn(); const DD_API_ORIGIN = 'https://api.datadoghq.com'; +const localOnlyHostCheck = createViteHostCheck({ server: { allowedHosts: [] }, preview: {} }); + const mockFunctions: BackendFunction[] = [ { relativePath: 'backend/greet', @@ -280,6 +285,7 @@ function createTestMiddleware( projectRoot?: CreateDevServerMiddlewareArgs[7]; log?: CreateDevServerMiddlewareArgs[8]; mode?: CreateDevServerMiddlewareArgs[9]; + isHostAllowed?: CreateDevServerMiddlewareArgs[10]; } = {}, ): ReturnType { return createDevServerMiddleware( @@ -295,6 +301,7 @@ function createTestMiddleware( overrides.projectRoot ?? '/project', overrides.log ?? mockLog, overrides.mode ?? 'development', + overrides.isHostAllowed ?? localOnlyHostCheck, ); } @@ -450,6 +457,100 @@ describe('Dev Server Middleware', () => { }); }); + describe('createDevServerMiddleware request guard', () => { + const routes = [...DEV_SERVER_ROUTES]; + const rejections = [ + { + description: 'a cross-site Origin', + headers: { origin: 'https://attacker.example' }, + expectedStatus: 403, + }, + { + description: 'a host Vite would not serve', + headers: { + host: 'attacker.example:5173', + origin: 'http://attacker.example:5173', + }, + expectedStatus: 403, + }, + { + description: 'a text/plain body', + headers: { 'content-type': 'text/plain' }, + expectedStatus: 415, + }, + ]; + const cases = routes.flatMap((route) => + rejections.map((rejection) => ({ route, ...rejection })), + ); + + test.each(cases)( + 'Should reject $route with $expectedStatus for $description before handling it', + async ({ route, headers, expectedStatus }) => { + const middleware = createTestMiddleware(); + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest(route, { functionName, args: [] }, headers); + const res = createMockResponse(); + const next = jest.fn(); + + middleware(req, res, next); + await res.done; + + const rawBody = res.getBody(); + const body = JSON.parse(rawBody); + + expect(res.statusCode).toBe(expectedStatus); + expect(body.success).toBe(false); + expect(next).not.toHaveBeenCalled(); + expect(mockViteBuild).not.toHaveBeenCalled(); + expect(mockLoadModule).not.toHaveBeenCalled(); + const routeMessage = expect.stringContaining(route); + expect(mockLogFn).toHaveBeenCalledWith(routeMessage, 'debug'); + }, + ); + + test('Should leave other /__dd paths to the next middleware', () => { + const middleware = createTestMiddleware(); + const req = createMockRequest( + '/__dd/sw.js', + {}, + { origin: 'https://attacker.example' }, + ); + const res = createMockResponse(); + const next = jest.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.end).not.toHaveBeenCalled(); + }); + + test('Should serve a request proxied under an allowed preview proxy host', async () => { + mockBuildWithParsedBackend(); + const previewHost = 'app-5173.preview.example'; + const previewHostCheck = createViteHostCheck({ + server: { allowedHosts: ['.preview.example'] }, + preview: {}, + }); + const middleware = createTestMiddleware({ + isHostAllowed: previewHostCheck, + }); + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest( + DEBUG_BUNDLE_ROUTE, + { functionName }, + { host: previewHost, origin: `https://${previewHost}` }, + ); + const res = createMockResponse(); + const next = jest.fn(); + + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + expect(next).not.toHaveBeenCalled(); + }); + }); + describe('debugBundle handler', () => { const middleware = createTestMiddleware(); @@ -1172,6 +1273,7 @@ describe('Dev Server Middleware', () => { '/project', mockLog, 'development', + localOnlyHostCheck, ); const req = createMockRequest('/__dd/executeAction', { @@ -1204,6 +1306,7 @@ describe('Dev Server Middleware', () => { '/project', mockLog, 'development', + localOnlyHostCheck, ); mockLoadModuleReturning(mockFunctions[0], () => 'recovered'); const recoveringReq = createMockRequest('/__dd/executeAction', { @@ -1504,6 +1607,7 @@ describe('Dev Server Middleware', () => { '/project', mockLog, 'development', + localOnlyHostCheck, ); const req = createMockRequest('/__dd/executeAction', { diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index d32f97736..c809ffa6b 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -22,6 +22,8 @@ import type { LongPollingOptions } from '../types'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { createBackendStaticChecksPlugin } from './backend-static-checks-plugin'; import { getBaseBackendBuildConfig } from './build-config'; +import type { HostCheck } from './dev-server-request-guard'; +import { checkDevServerRequest } from './dev-server-request-guard'; import type { ExecuteAction, GetRuntimeContext, LoadModule } from './local-execution'; import { DEFAULT_TIMEOUT_MS, executeColdActionLocally } from './local-execution'; import { getMaxRetryDelayMs } from './retry-delay'; @@ -35,6 +37,28 @@ type BundleFn = (func: BackendFunction) => Promise; const DEV_VIRTUAL_PREFIX = 'virtual:dd-backend-dev:'; +export const DEBUG_BUNDLE_ROUTE = '/__dd/debugBundle'; +export const EXECUTE_ACTION_ROUTE = '/__dd/executeAction'; +export const EXECUTE_ACTION_VIA_CLOUD_ROUTE = '/__dd/executeActionViaCloud'; + +export const DEV_SERVER_ROUTES = [ + DEBUG_BUNDLE_ROUTE, + EXECUTE_ACTION_ROUTE, + EXECUTE_ACTION_VIA_CLOUD_ROUTE, +] as const; + +type DevServerRoute = (typeof DEV_SERVER_ROUTES)[number]; + +type RouteHandler = ( + req: IncomingMessage, + res: ServerResponse, + functionsByName: Map, +) => void; + +function isDevServerRoute(url: string | undefined): url is DevServerRoute { + return DEV_SERVER_ROUTES.some((route) => route === url); +} + type AuthConfig = AuthOptionsWithDefaults; type LongPollingConfig = Required; @@ -697,6 +721,7 @@ export function createDevServerMiddleware( projectRoot: string, log: Logger, mode: string, + isHostAllowed: HostCheck, ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { const bundle = (func: BackendFunction) => bundleBackendFunction(viteBuild, func, projectRoot, log); @@ -709,50 +734,59 @@ export function createDevServerMiddleware( ); } - return (req: IncomingMessage, res: ServerResponse, next: () => void) => { - if (req.method !== 'POST') { - next(); - return; - } - - const functionsByName = buildFunctionMap(getBackendFunctions()); - - if (req.url === '/__dd/debugBundle') { - handleDebugBundle(req, res, functionsByName, bundle).catch(() => { - sendError(res, 500, 'Unexpected error'); - }); - } else if ( - req.url === '/__dd/executeActionViaCloud' || - (req.url === '/__dd/executeAction' && isDevVerifyMode) - ) { - // The client always POSTs to /__dd/executeAction regardless of mode, so dev-verify is routed here server-side instead. - routeToCloudHandler( + const runViaCloud: RouteHandler = (req, res, functionsByName) => + routeToCloudHandler( + req, + res, + functionsByName, + bundle, + auth, + doAuthenticatedRequest, + longPolling, + log, + ); + const runLocally: RouteHandler = (req, res, functionsByName) => + guardAuthenticated(res, doAuthenticatedRequest, (authedRequest) => + handleExecuteAction( req, res, functionsByName, - bundle, auth, - doAuthenticatedRequest, + authedRequest, longPolling, + loadModule, + getAllowedConnectionIds, + projectRoot, log, - ); - } else if (req.url === '/__dd/executeAction') { - guardAuthenticated(res, doAuthenticatedRequest, (authedRequest) => - handleExecuteAction( - req, - res, - functionsByName, - auth, - authedRequest, - longPolling, - loadModule, - getAllowedConnectionIds, - projectRoot, - log, - ), - ); - } else { + ), + ); + const routeHandlers: Record = { + [DEBUG_BUNDLE_ROUTE]: (req, res, functionsByName) => { + handleDebugBundle(req, res, functionsByName, bundle).catch(() => { + sendError(res, 500, 'Unexpected error'); + }); + }, + // The client always POSTs to /__dd/executeAction regardless of mode, so dev-verify is routed to the cloud server-side instead. + [EXECUTE_ACTION_ROUTE]: isDevVerifyMode ? runViaCloud : runLocally, + [EXECUTE_ACTION_VIA_CLOUD_ROUTE]: runViaCloud, + }; + + return (req: IncomingMessage, res: ServerResponse, next: () => void) => { + const { url } = req; + if (req.method !== 'POST' || !isDevServerRoute(url)) { next(); + return; } + + const rejection = checkDevServerRequest(req.headers, isHostAllowed); + if (rejection) { + log.debug(`Rejected POST ${url}: ${rejection.message}`); + sendError(res, rejection.statusCode, rejection.message); + return; + } + + const backendFunctions = getBackendFunctions(); + const functionsByName = buildFunctionMap(backendFunctions); + routeHandlers[url](req, res, functionsByName); }; } diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 6d65b5c5c..68d3cc7b0 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -2,6 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import type { HostCheckConfig } from '@dd/apps-plugin/vite/dev-server-request-guard'; import { getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { localExecutionResolutionContext } from '@dd/apps-plugin/vite/local-execution'; @@ -91,10 +92,14 @@ type DevServerMiddleware = (req: IncomingMessage, res: ServerResponse, next: () type FakeViteDevServer = { middlewares: { use: (fn: DevServerMiddleware) => void }; ssrLoadModule: (id: string) => Promise; - config: { mode: string }; + config: { mode: string } & HostCheckConfig; pluginContainer?: object; }; +function fakeServerConfig(mode: string): FakeViteDevServer['config'] { + return { server: { allowedHosts: [] }, preview: {}, mode }; +} + // Narrows `plugin.configureServer` to its plain-function hook form via a runtime check, then wraps // it in a signature scoped to the fake server this test passes, since the real hook's `ViteDevServer` // parameter is far wider than what these tests construct — mirrors `getTransformHandler` above. @@ -316,7 +321,7 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + config: fakeServerConfig('development'), } as unknown as ViteDevServer; // The hooks are typed with Rollup's `this: PluginContext`, but the // plugin closures never read `this`, so a stand-in satisfies the call. @@ -345,7 +350,7 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + config: fakeServerConfig('development'), } as unknown as ViteDevServer; plugin.configureServer(server); @@ -373,7 +378,7 @@ describe('Backend Functions - getVitePlugin', () => { const server = { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + config: fakeServerConfig('development'), } as unknown as ViteDevServer; plugin.configureServer(server); @@ -1204,7 +1209,7 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode }, + config: fakeServerConfig(mode), }); expect(installGuards).toHaveBeenCalledTimes(installs); @@ -1237,7 +1242,7 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + config: fakeServerConfig('development'), }); const warned = mockLogFn.mock.calls.some( ([text, level]) => @@ -1270,7 +1275,7 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + config: fakeServerConfig('development'), }), ).not.toThrow(); expect(installGuards).toHaveBeenCalled(); @@ -1314,7 +1319,7 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), - config: { mode: 'development' }, + config: fakeServerConfig('development'), pluginContainer, }); const inRunPath = path.join(cacheDir, 'in-run.txt'); @@ -1384,7 +1389,7 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use }, ssrLoadModule, - config: { mode: DEV_VERIFY_MODE }, + config: fakeServerConfig(DEV_VERIFY_MODE), }); restoreEnv(); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index c7939b5cb..fdc2ad6cd 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -41,6 +41,7 @@ import type { AppsOptionsWithDefaults } from '../types'; import { buildBackendFunctions } from './build-backend-functions'; import { buildAppPackage } from './build-package'; import { collectModuleGraphFromServer } from './dev-server-module-graph'; +import { createViteHostCheck } from './dev-server-request-guard'; import { createDevServerMiddleware } from './dev-server'; import { localExecutionResolutionContext } from './local-execution'; import { @@ -520,6 +521,7 @@ export const getVitePlugin = ({ ); return extractConnectionIdsFromModuleGraph(entryId, moduleGraph, context.buildRoot); }; + const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( bundler.build, loadModule, @@ -531,6 +533,7 @@ export const getVitePlugin = ({ context.buildRoot, log, server.config.mode, + isHostAllowed, ); server.middlewares.use(middleware); }, diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index 1ae7fb9e1..6a51a4f57 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -52,7 +52,7 @@ import type { PluginBuild, Metafile } from 'esbuild'; import esbuild from 'esbuild'; import { EventEmitter } from 'events'; import type { PathLike, Stats } from 'fs'; -import type { IncomingMessage, ServerResponse } from 'http'; +import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'http'; import path from 'path'; import { getTempWorkingDir } from './env'; @@ -148,12 +148,22 @@ export const moduleResolverFor = ( }; /** - * Create a mock IncomingMessage with a JSON body. + * Create a mock same-origin JSON POST from the dev server's own page. */ -export function createMockRequest(url: string, body: Record): IncomingMessage { +export function createMockRequest( + url: string, + body: Record, + headers: IncomingHttpHeaders = {}, +): IncomingMessage { const req = new EventEmitter() as unknown as IncomingMessage; req.method = 'POST'; req.url = url; + req.headers = { + host: 'localhost:5173', + origin: 'http://localhost:5173', + 'content-type': 'application/json', + ...headers, + }; // Simulate body stream in next tick. process.nextTick(() => {