Skip to content

[APPS-2953] Fix: check Origin, Host and Content-Type on the apps dev-server routes - #588

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 1 commit into
masterfrom
tiffany.trinh/apps-2953-dev-server-origin-check
Oct 16, 2026
Merged

gh-worker-dd-mergequeue-cf854d[bot] merged 1 commit into
masterfrom
tiffany.trinh/apps-2953-dev-server-origin-check

Conversation

@tyffical

@tyffical tyffical commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

  • The apps dev server's /__dd/debugBundle, /__dd/executeAction and /__dd/executeActionViaCloud routes are registered from configureServer.
    • On Vite 5 and 6 they run ahead of Vite's own CORS and server.allowedHosts middleware.
    • Before this change, they checked only the HTTP method.
  • These routes should answer only the dev server's own page, under a host the developer has allowed, like the rest of the Vite dev server.
  • Jira: APPS-2953

Architecture

 browser page ──POST /__dd/*──▶ Vite connect stack
                                  │
                                  ▼
                 ┌──────────────────────────────────────┐
                 │ apps plugin middleware               │
                 │  (configureServer, before Vite's     │
                 │   cors + host check on Vite 5/6)     │
                 │                                      │
                 │  checkDevServerRequest               │
                 │   1. Host passes allowedHosts rules  │──▶ 403
                 │   2. Origin (if sent) == Host        │──▶ 403
                 │   3. Content-Type: application/json  │──▶ 415
                 └──────────────────┬───────────────────┘
                                    ▼
                 routeHandlers[url]: debugBundle │ executeAction │ executeActionViaCloud
  • The host check ports Vite's server.allowedHosts rules, because Vite doesn't export its check. It reads the resolved Vite config, including hosts added through Vite's __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS.
  • The Origin is compared with the Host on host and port only. Behind a TLS-terminating proxy, the browser Origin is https while the dev server listens over http.
  • Other /__dd/* paths and non-POST requests go to the next middleware as before.

Changes

9 changes across dev-server-request-guard.ts, dev-server.ts, index.ts, README.md and tests
What changed File
Adds the request guard: a Vite-compatible host check, an Origin-to-Host match and a JSON content-type requirement. packages/plugins/apps/src/vite/dev-server-request-guard.ts
Rejects guarded POSTs before any handler runs, and dispatches through a complete route-to-handler record. packages/plugins/apps/src/vite/dev-server.ts
Builds the host check from the resolved Vite config and passes it to the middleware. packages/plugins/apps/src/vite/index.ts
Documents what the dev server's backend function endpoints accept, with a curl example. packages/plugins/apps/README.md
Table-driven unit tests for the host check and the request decision. packages/plugins/apps/src/vite/dev-server-request-guard.test.ts
Middleware tests: each route rejects before handling, other /__dd paths fall through, and an allowed preview proxy host is served. packages/plugins/apps/src/vite/dev-server.test.ts
Real-HTTP tests through the configureServer-installed middleware, including a host-by-host parity check against Vite's own host check. packages/plugins/apps/src/vite/dev-server.integration.test.ts
Fake dev servers carry a resolved server/preview config. packages/plugins/apps/src/vite/index.test.ts
createMockRequest sends same-origin JSON headers by default, overridable per test. packages/tests/src/_jest/helpers/mocks.ts

QA Instructions

yarn install
yarn build:all
# Expected: exit 0 ✅ VERIFIED
yarn typecheck:all
# Expected: exit 0 ✅ VERIFIED
yarn lint
# Expected: exit 0 ✅ VERIFIED
yarn cli integrity
# Expected: exit 0, no changes to commit ✅ VERIFIED
yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/dev-server-request-guard.test.ts packages/plugins/apps/src/vite/dev-server.test.ts packages/plugins/apps/src/vite/index.test.ts packages/plugins/apps/src/vite/dev-server.integration.test.ts
# Expected: Tests: 222 passed, 222 total ✅ VERIFIED
yarn workspace @dd/tests test:unit
# Expected on macOS: 3313 passed; the only failures are manifest-tags.integration and parameters.integration, which fail locally because /var is a symlink to /private/var ✅ VERIFIED
TMPDIR=/private/tmp/<dir> yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/manifest-tags.integration.test.ts packages/plugins/apps/src/vite/parameters.integration.test.ts
# Expected: Tests: 33 passed, 33 total ✅ VERIFIED
  • Manual QA: a real Vite dev server with the published plugin build, once from master and once from this branch, driven with curl (HTTP/1.1 and HTTP/2).
    • On this branch, requests from another origin, with a text/plain body, or under a Host outside allowedHosts are rejected.
    • Same-origin JSON requests on localhost and under an allowed preview proxy host still get 200.
    • In Chromium and Firefox, a page on another origin gets 403 for a no-cors fetch, a CORS JSON fetch and a form POST, read from the dev server's request log.
    • A real vite dev session and the Vite-version parity check are in the second reproduction below.
# Before (master): rows 1 to 4 reach their handlers
1 other origin, text/plain, POST /__dd/debugBundle         200 ✅ VERIFIED
2 other origin, text/plain, POST /__dd/executeAction       400 (auth gate reached) ✅ VERIFIED
3 Host not in allowedHosts, matching Origin, JSON          200 ✅ VERIFIED
4 own origin, text/plain, POST /__dd/debugBundle           200 ✅ VERIFIED
# After (this branch)
1 other origin, text/plain, POST /__dd/debugBundle         403 ✅ VERIFIED
2 other origin, text/plain, POST /__dd/executeAction       403 ✅ VERIFIED
3 Host not in allowedHosts, matching Origin, JSON          403 ✅ VERIFIED
4 own origin, text/plain, POST /__dd/debugBundle           415 ✅ VERIFIED
5 own origin (localhost), JSON, /__dd/debugBundle          200 ✅ VERIFIED
6 preview proxy host, https Origin, JSON, debugBundle      200 ✅ VERIFIED
7 own origin, JSON, /__dd/executeAction (no auth)          400 (auth gate reached) ✅ VERIFIED
h2 own origin, JSON, POST /__dd/debugBundle    200 HTTP/2 ✅ VERIFIED
h2 other origin, JSON, POST /__dd/debugBundle  403 HTTP/2 ✅ VERIFIED
# Browser on another origin (Chromium, Firefox): no-cors fetch / CORS JSON fetch / form POST
master  200 / 200 / 400 (handlers reached) ✅ VERIFIED
branch  403 / 403 / 403 ✅ VERIFIED
# Real `vite dev`, same-origin page, branch and master alike
generated client call, POST /__dd/executeAction   reaches the handler ✅ VERIFIED
page fetch, JSON, POST /__dd/debugBundle          200 ✅ VERIFIED
# Host check parity with Vite 5.4.21, 6.0.9, 6.4.3, 7.3.1 and 8.3.4
1170 host/config pairs, 0 mismatches beyond the dropped file:/-extension: allowance ✅ VERIFIED
Manual QA reproduction: scripts, run command and full captured output

Layout: one directory holding the four scripts and an app/ fixture. FIXED is a checkout of this branch and BASELINE a checkout of master, each with yarn install and yarn workspace @datadog/vite-plugin build run.

app/index.html

<!doctype html>
<html><body><script type="module" src="/main.js"></script></body></html>

app/main.js

import { greet } from './backend/greet.backend.ts';
window.__greet = greet;

app/backend/greet.backend.ts

export async function greet(name: string) {
    return `hello ${name}`;
}

qa-server.cjs

// Run: W=<build-plugins checkout with built dist> PORT=<port> [HTTPS=1] node qa-server.cjs
const fs = require('fs');
const path = require('path');
const { W, PORT, HTTPS } = process.env;
const { createServer } = require(`${W}/node_modules/vite`);
const { datadogVitePlugin } = require(`${W}/packages/published/vite-plugin/dist/src/index.js`);
const root = path.join(__dirname, 'app');
const requestLogger = {
    name: 'qa-request-logger',
    configureServer(server) {
        server.middlewares.use((req, res, next) => {
            res.on('finish', () => {
                const host = req.headers.host ?? req.headers[':authority'] ?? '-';
                console.log(`[qa] ${req.method} ${req.url} -> ${res.statusCode} host=${host} origin=${req.headers.origin ?? '-'} type=${req.headers['content-type'] ?? '-'}`);
            });
            next();
        });
    },
};
(async () => {
    const https = HTTPS === '1'
        ? { key: fs.readFileSync(path.join(__dirname, 'tls/key.pem')), cert: fs.readFileSync(path.join(__dirname, 'tls/cert.pem')) }
        : undefined;
    const server = await createServer({
        configFile: false,
        root,
        cacheDir: path.join(__dirname, `vite-cache-${PORT}`),
        logLevel: 'warn',
        server: { host: '127.0.0.1', port: Number(PORT), strictPort: true, hmr: false, https },
        optimizeDeps: { noDiscovery: true },
        plugins: [requestLogger, datadogVitePlugin({ apps: { enable: true } })],
    });
    await server.listen();
    console.log(`QA_SERVER_READY ${PORT}`);
})().catch((error) => {
    console.error(error);
    process.exit(1);
});

qa-curl.sh

#!/bin/bash
# Run: PORT=<port> ./qa-curl.sh   (the server must already be running)
PORT=${PORT:?}
BASE="http://127.0.0.1:${PORT}"
FN=$(node -e "const c=require('crypto');console.log(c.createHash('sha256').update('backend/greet').digest('hex')+'.greet')")
PREVIEW_HOST="app-${PORT}.preview.example"
# Vite registers backend functions when it transforms the importing module.
curl -s -o /dev/null "${BASE}/main.js" -H "Host: localhost:${PORT}"
curl -s -o /dev/null "${BASE}/backend/greet.backend.ts" -H "Host: localhost:${PORT}"
run() {
    local label=$1; shift
    local out
    out=$(curl -s -w '\n%{http_code}' "$@")
    local code=${out##*$'\n'}
    local body=${out%$'\n'*}
    printf '%-58s %s  %s\n' "$label" "$code" "$(printf '%s' "$body" | tr '\n' ' ' | cut -c1-90)"
}
run "1 other origin, text/plain, POST /__dd/debugBundle" -X POST "${BASE}/__dd/debugBundle" -H "Host: localhost:${PORT}" -H 'Origin: https://other.example' -H 'Content-Type: text/plain' --data "{\"functionName\":\"${FN}\"}"
run "2 other origin, text/plain, POST /__dd/executeAction" -X POST "${BASE}/__dd/executeAction" -H "Host: localhost:${PORT}" -H 'Origin: https://other.example' -H 'Content-Type: text/plain' --data "{\"functionName\":\"${FN}\",\"args\":[\"x\"]}"
run "3 Host not in allowedHosts, matching Origin, JSON" -X POST "${BASE}/__dd/debugBundle" -H "Host: other.example:${PORT}" -H "Origin: http://other.example:${PORT}" -H 'Content-Type: application/json' --data "{\"functionName\":\"${FN}\"}"
run "4 own origin, text/plain, POST /__dd/debugBundle" -X POST "${BASE}/__dd/debugBundle" -H "Host: localhost:${PORT}" -H "Origin: http://localhost:${PORT}" -H 'Content-Type: text/plain' --data "{\"functionName\":\"${FN}\"}"
run "5 own origin (localhost), JSON, /__dd/debugBundle" -X POST "${BASE}/__dd/debugBundle" -H "Host: localhost:${PORT}" -H "Origin: http://localhost:${PORT}" -H 'Content-Type: application/json' --data "{\"functionName\":\"${FN}\"}"
run "6 preview proxy host, https Origin, JSON, debugBundle" -X POST "${BASE}/__dd/debugBundle" -H "Host: ${PREVIEW_HOST}" -H "Origin: https://${PREVIEW_HOST}" -H 'Content-Type: application/json' --data "{\"functionName\":\"${FN}\"}"
run "7 own origin, JSON, /__dd/executeAction (no auth)" -X POST "${BASE}/__dd/executeAction" -H "Host: localhost:${PORT}" -H "Origin: http://localhost:${PORT}" -H 'Content-Type: application/json' --data "{\"functionName\":\"${FN}\",\"args\":[\"x\"]}"

qa-https-curl.sh

#!/bin/bash
# Run: PORT=<https port> ./qa-https-curl.sh   (server started with HTTPS=1)
PORT=${PORT:?}
BASE="https://localhost:${PORT}"
FN=$(node -e "const c=require('crypto');console.log(c.createHash('sha256').update('backend/greet').digest('hex')+'.greet')")
CURL=(curl -sk --http2 --resolve "localhost:${PORT}:127.0.0.1")
"${CURL[@]}" -o /dev/null "${BASE}/main.js"
"${CURL[@]}" -o /dev/null "${BASE}/backend/greet.backend.ts"
run() {
    local label=$1; shift
    local out
    out=$("${CURL[@]}" -w '\n%{http_code} HTTP/%{http_version}' "$@")
    printf '%-46s %s  %s\n' "$label" "${out##*$'\n'}" "$(printf '%s' "${out%$'\n'*}" | tr '\n' ' ' | cut -c1-70)"
}
run "h2 own origin, JSON, POST /__dd/debugBundle" -X POST "${BASE}/__dd/debugBundle" -H "Origin: https://localhost:${PORT}" -H 'Content-Type: application/json' --data "{\"functionName\":\"${FN}\"}"
run "h2 other origin, JSON, POST /__dd/debugBundle" -X POST "${BASE}/__dd/debugBundle" -H 'Origin: https://other.example' -H 'Content-Type: application/json' --data "{\"functionName\":\"${FN}\"}"

qa-run-all.sh

#!/bin/bash
# Run: FIXED=<checkout with this branch built> BASELINE=<checkout of master built> ./qa-run-all.sh
set -u
cd "$(dirname "$0")"
start() { # $1 checkout, $2 port, $3 https flag
    (exec env -u DD_API_KEY -u DD_APP_KEY -u DATADOG_API_KEY -u DATADOG_APP_KEY -u DD_OAUTH_ACCESS_TOKEN -u DATADOG_OAUTH_ACCESS_TOKEN \
        __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.preview.example W="$1" PORT="$2" HTTPS="$3" node qa-server.cjs > "server-$2.log" 2>&1) &
    PIDS+=($!)
    for _ in $(seq 1 60); do grep -q QA_SERVER_READY "server-$2.log" && return; sleep 1; done
    echo "server on $2 did not start"; cat "server-$2.log"; exit 1
}
PIDS=()
mkdir -p tls
openssl req -x509 -newkey rsa:2048 -nodes -keyout tls/key.pem -out tls/cert.pem -days 1 -subj "/CN=localhost" 2>/dev/null
start "$BASELINE" 5292 0
start "$FIXED" 5291 0
start "$FIXED" 5293 1
echo "== Before (master)"; PORT=5292 ./qa-curl.sh
echo "== After (this branch)"; PORT=5291 ./qa-curl.sh
echo "== After, HTTPS over HTTP/2"; PORT=5293 ./qa-https-curl.sh
# `node` can be a version-manager shim that runs the real node as its child.
for pid in "${PIDS[@]}"; do pkill -TERM -P "$pid"; kill "$pid"; done
wait 2>/dev/null
rm -rf tls vite-cache-5291 vite-cache-5292 vite-cache-5293

Run command:

FIXED=<this branch checkout> BASELINE=<master checkout> ./qa-run-all.sh

Captured output:

== Before (master)
1 other origin, text/plain, POST /__dd/debugBundle         200  async function greet(name) {   return `hello ${name}`; }  /** @param {import("./context.ty
2 other origin, text/plain, POST /__dd/executeAction       400  {"success":false,"error":"Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY f
3 Host not in allowedHosts, matching Origin, JSON          200  async function greet(name) {   return `hello ${name}`; }  /** @param {import("./context.ty
4 own origin, text/plain, POST /__dd/debugBundle           200  async function greet(name) {   return `hello ${name}`; }  /** @param {import("./context.ty
5 own origin (localhost), JSON, /__dd/debugBundle          200  async function greet(name) {   return `hello ${name}`; }  /** @param {import("./context.ty
6 preview proxy host, https Origin, JSON, debugBundle      200  async function greet(name) {   return `hello ${name}`; }  /** @param {import("./context.ty
7 own origin, JSON, /__dd/executeAction (no auth)          400  {"success":false,"error":"Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY f
== After (this branch)
1 other origin, text/plain, POST /__dd/debugBundle         403  {"success":false,"error":"Blocked request. Only the dev server's own origin can call this 
2 other origin, text/plain, POST /__dd/executeAction       403  {"success":false,"error":"Blocked request. Only the dev server's own origin can call this 
3 Host not in allowedHosts, matching Origin, JSON          403  {"success":false,"error":"Blocked request. This host (\"other.example\") is not allowed. T
4 own origin, text/plain, POST /__dd/debugBundle           415  {"success":false,"error":"Requests must use Content-Type: application/json."}
5 own origin (localhost), JSON, /__dd/debugBundle          200  async function greet(name) {   return `hello ${name}`; }  /** @param {import("./context.ty
6 preview proxy host, https Origin, JSON, debugBundle      200  async function greet(name) {   return `hello ${name}`; }  /** @param {import("./context.ty
7 own origin, JSON, /__dd/executeAction (no auth)          400  {"success":false,"error":"Auth credentials not configured. Set DD_API_KEY and DD_APP_KEY f
== After, HTTPS over HTTP/2
h2 own origin, JSON, POST /__dd/debugBundle    200 HTTP/2  async function greet(name) {   return `hello ${name}`; }  /** @param {
h2 other origin, JSON, POST /__dd/debugBundle  403 HTTP/2  {"success":false,"error":"Blocked request. Only the dev server's own o
Second reproduction: real vite dev, Vite-version parity and the integration parity rows

Real vite dev with a same-origin page. vite-dev-app/ holds a copy of the app/ fixture plus this config. The dev server runs with local test keys and a preload that fails every non-loopback socket, so the executeAction handler's Datadog API call fails with fetch failed (500) on this branch and on master alike: the request passed the guard and reached the handler.

vite-dev-app/vite.config.mjs

// The built plugin is loaded from the checkout named by W.
const { datadogVitePlugin } = await import(`${process.env.W}/packages/published/vite-plugin/dist/src/index.mjs`);

const requestLogger = {
    name: 'qa-request-logger',
    configureServer(server) {
        server.middlewares.use((req, res, next) => {
            if (req.url.startsWith('/__dd/')) {
                res.on('finish', () => {
                    console.log(`[qa] ${req.method} ${req.url} -> ${res.statusCode} origin=${req.headers.origin ?? '-'} type=${req.headers['content-type'] ?? '-'}`);
                });
            }
            next();
        });
    },
};

export default {
    logLevel: 'info',
    server: { host: '127.0.0.1', port: 5295, strictPort: true },
    plugins: [requestLogger, datadogVitePlugin({ apps: { enable: true } })],
};

loopback-only.cjs

// Preloaded with `node -r`: fails and logs any outbound socket that isn't loopback.
const net = require('net');
const original = net.Socket.prototype.connect;
net.Socket.prototype.connect = function (...args) {
    const options = typeof args[0] === 'object' ? args[0] : { port: args[0], host: args[1] };
    const host = options.host ?? 'localhost';
    if (!options.path && !['127.0.0.1', '::1', 'localhost'].includes(host)) {
        console.log(`[qa] BLOCKED outbound connection to ${host}:${options.port}`);
        process.nextTick(() => this.destroy(new Error(`blocked outbound connection to ${host}`)));
        return this;
    }
    return original.apply(this, args);
};

qa-vite-dev-client.cjs

// Run: W=<checkout> node qa-vite-dev-client.cjs   (with `vite dev` already serving vite-dev-app on 5295)
const { W } = process.env;
const { chromium } = require(`${W}/node_modules/playwright`);
(async () => {
    const browser = await chromium.launch();
    const page = await browser.newPage();
    await page.goto('http://localhost:5295/');
    await page.waitForFunction(() => typeof window.__greet === 'function', null, { timeout: 30000 });
    // The generated client stub, which POSTs to /__dd/executeAction through the dev-server transport.
    const clientCall = await page.evaluate(() => window.__greet('qa').then(String, (e) => `threw: ${e.message}`));
    // A same-origin page fetch to /__dd/debugBundle, which needs no Datadog API call.
    const debugBundle = await page.evaluate(async () => {
        const functionName = (await (await fetch('/backend/greet.backend.ts')).text()).match(/"([0-9a-f]{64}\.greet)"/)?.[1];
        const response = await fetch('/__dd/debugBundle', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ functionName }),
        });
        return `${response.status} ${(await response.text()).slice(0, 40).replace(/\s+/g, ' ')}`;
    });
    console.log(JSON.stringify({ origin: await page.evaluate(() => location.origin), clientCall, debugBundle }));
    await browser.close();
})();

qa-vite-dev.sh

#!/bin/bash
# Run: W=<checkout with this branch built> ./qa-vite-dev.sh
set -u
cd "$(dirname "$0")"; QA_DIR=$PWD
(cd vite-dev-app && exec env -u DD_OAUTH_ACCESS_TOKEN -u DATADOG_OAUTH_ACCESS_TOKEN -u DATADOG_API_KEY -u DATADOG_APP_KEY \
    DD_API_KEY=qa-local-api-key DD_APP_KEY=qa-local-app-key NODE_OPTIONS="-r $QA_DIR/loopback-only.cjs" W="$W" \
    node "$W/node_modules/vite/bin/vite.js" --debug apps > ../vite-dev.log 2>&1) &
PID=$!
for _ in $(seq 1 60); do grep -q 'Local:' vite-dev.log && break; sleep 1; done
W="$W" node qa-vite-dev-client.cjs
sleep 1
grep -a -E '^\[qa\]|Rejected' vite-dev.log | sed 's/\x1b\[[0-9;]*m//g' | cut -c1-160
pkill -TERM -P "$PID"; kill "$PID"; wait 2>/dev/null
rm -rf vite-dev-app/node_modules/.vite

Run command and captured output (branch, then master):

W=<this branch checkout> ./qa-vite-dev.sh
W=<master checkout> ./qa-vite-dev.sh
== branch
{"origin":"http://localhost:5295","clientCall":"threw: Backend function \"752d23f765f2d244b28ecd9c68ca8ce2336cb9ed33f7432b8e10a38ca76c892f.greet\" failed with status 500: {\"success\":false,\"error\":\"fetch failed\"}","debugBundle":"200 async function greet(name) { return `h"}
[qa] BLOCKED outbound connection to browser-http-intake.logs.datadoghq.com:443
[qa] BLOCKED outbound connection to api.datadoghq.com:443
[qa] POST /__dd/executeAction -> 500 origin=http://localhost:5295 type=application/json
[qa] POST /__dd/debugBundle -> 200 origin=http://localhost:5295 type=application/json
== master
{"origin":"http://localhost:5295","clientCall":"threw: Backend function \"752d23f765f2d244b28ecd9c68ca8ce2336cb9ed33f7432b8e10a38ca76c892f.greet\" failed with status 500: {\"success\":false,\"error\":\"fetch failed\"}","debugBundle":"200 async function greet(name) { return `h"}
[qa] BLOCKED outbound connection to browser-http-intake.logs.datadoghq.com:443
[qa] BLOCKED outbound connection to api.datadoghq.com:443
[qa] POST /__dd/executeAction -> 500 origin=http://localhost:5295 type=application/json
[qa] POST /__dd/debugBundle -> 200 origin=http://localhost:5295 type=application/json

Host check parity across Vite versions. parity.cjs lifts Vite's own host-check functions out of each version's published dist and compares them with createViteHostCheck, bundled from this branch.

for v in 5.4.21 6.0.9 6.4.3 7.3.1 8.3.4; do mkdir -p dl/vite_$v && (cd dl/vite_$v && npm pack vite@$v >/dev/null && tar xzf vite-$v.tgz); done
node_modules/.bin/esbuild packages/plugins/apps/src/vite/dev-server-request-guard.ts --bundle --platform=node --format=cjs --outfile=guard.cjs
DL=dl node parity.cjs ./guard.cjs

parity.cjs

// Run: DL=<dir with vite_<version>/package> node parity.cjs <guard.cjs>
const fs = require('fs');
const net = require('net');
const { createViteHostCheck } = require(process.argv[2]);
const DL = process.env.DL;
const findChunk = (v) => {
    const dir = `${DL}/${v}/package/dist/node/chunks`;
    return fs.readdirSync(dir).map((f) => `${dir}/${f}`)
        .find((f) => fs.readFileSync(f, 'utf8').includes('function getAdditionalAllowedHosts'));
};
const extract = (src, name) => {
    const start = src.indexOf(`function ${name}(`);
    if (start < 0) return '';
    let i = src.indexOf('{', start), depth = 0;
    for (; i < src.length; i++) {
        if (src[i] === '{') depth++;
        else if (src[i] === '}' && --depth === 0) break;
    }
    return src.slice(start, i + 1);
};
const loadVite = (v) => {
    const src = fs.readFileSync(findChunk(v), 'utf8');
    const names = ['getAdditionalAllowedHosts', 'isHostAllowedWithoutCache', 'extractHostNameFromHostHeader', 'isHostAllowedInternal'];
    const body = `const isFileOrExtensionProtocolRE = /^(?:file|.+-extension):/i;\n${names.map((n) => extract(src, n)).join('\n')}\n` +
        `return { getAdditionalAllowedHosts, isHostAllowedWithoutCache: typeof isHostAllowedWithoutCache === 'function' ? isHostAllowedWithoutCache : undefined, isHostAllowedInternal: typeof isHostAllowedInternal === 'function' ? isHostAllowedInternal : undefined };`;
    return new Function('net', 'net$1', body)(net, net);
};
// Mirrors how each version resolves the config and runs its host check on a dev-server request.
const viteDecision = (fns, rawConfig, host) => {
    const server = structuredClone(rawConfig.server), preview = structuredClone(rawConfig.preview);
    if (server.https) return { allowed: true, ourConfig: { server, preview } };
    const additional = fns.getAdditionalAllowedHosts(server, preview);
    if (fns.isHostAllowedInternal) {
        if (Array.isArray(server.allowedHosts)) server.allowedHosts.push(...additional);
        const allowed = server.allowedHosts === true || fns.isHostAllowedInternal(host, server.allowedHosts);
        return { allowed, ourConfig: { server, preview } };
    }
    const allowed = server.allowedHosts === true || fns.isHostAllowedWithoutCache(server.allowedHosts, additional, host);
    return { allowed, ourConfig: { server, preview } };
};
const configs = {
    none: { server: { allowedHosts: [] }, preview: {} },
    suffix: { server: { allowedHosts: ['.preview.example'] }, preview: {} },
    exact: { server: { allowedHosts: ['dev.example'] }, preview: {} },
    all: { server: { allowedHosts: true }, preview: {} },
    https: { server: { allowedHosts: [], https: {} }, preview: {} },
    serverHost: { server: { allowedHosts: [], host: 'devbox.test' }, preview: {} },
    hmrHost: { server: { allowedHosts: [], hmr: { host: 'hmr.test' }, ws: { host: 'hmr.test' } }, preview: {} },
    origin: { server: { allowedHosts: [], origin: 'https://assets.example:8443' }, preview: {} },
    previewHost: { server: { allowedHosts: [] }, preview: { host: 'preview.test' } },
};
const hosts = ['localhost:5173', 'localhost', '127.0.0.1:5173', '10.0.0.5', '[::1]:5173', '[::1', '[fe80::zz]:5173', '[::1]x',
    'app.localhost:5173', 'LOCALHOST:5173', ' localhost:5173 ', 'localhost.:5173', 'attacker.example:5173', 'dev.example:5173',
    'sub.dev.example', 'app-5173.preview.example', 'preview.example', 'notpreview.example', 'devbox.test:5173', 'hmr.test',
    'evil.hmr.test', 'assets.example', 'preview.test:4173', 'build-extension:5173', 'file:x', 'localhost:5173@attacker.example'];
const KNOWN = (host) => /^(file:|.+-extension:)/i.test(host);
let total = 0, mismatches = 0, intended = 0;
for (const v of ['vite_5.4.21', 'vite_6.0.9', 'vite_6.4.3', 'vite_7.3.1', 'vite_8.3.4']) {
    const fns = loadVite(v);
    let vMismatch = 0, vTotal = 0;
    for (const [cname, cfg] of Object.entries(configs)) {
        for (const host of hosts) {
            const { allowed, ourConfig } = viteDecision(fns, cfg, host);
            const ours = createViteHostCheck(ourConfig)(host);
            vTotal++;
            if (ours !== allowed) {
                if (KNOWN(host) && allowed && !ours) { intended++; continue; }
                vMismatch++;
                console.log(`MISMATCH ${v} ${cname} ${JSON.stringify(host)} vite=${allowed} ours=${ours}`);
            }
        }
    }
    total += vTotal; mismatches += vMismatch;
    console.log(`${v}: ${vTotal} host/config pairs, ${vMismatch} mismatches`);
}
console.log(`TOTAL ${total} pairs, ${mismatches} mismatches, ${intended} intended file:/-extension: differences`);
vite_5.4.21: 234 host/config pairs, 0 mismatches
vite_6.0.9: 234 host/config pairs, 0 mismatches
vite_6.4.3: 234 host/config pairs, 0 mismatches
vite_7.3.1: 234 host/config pairs, 0 mismatches
vite_8.3.4: 234 host/config pairs, 0 mismatches
TOTAL 1170 pairs, 0 mismatches, 70 intended file:/-extension: differences

Integration parity rows (real HTTP through the configureServer-installed middleware, compared with Vite's own host check on a GET):

yarn workspace @dd/tests test:unit packages/plugins/apps/src/vite/dev-server.integration.test.ts -t 'request origin and host checks over real HTTP' --verbose
    ✓ Should serve a same-origin JSON request on localhost
    ✓ Should serve a request proxied under an allowed preview proxy host
    ✓ Should reject /__dd/executeAction with 403 for a cross-site Origin
    ✓ Should reject /__dd/executeAction with 415 for a text/plain body
    ✓ Should reject /__dd/executeAction with 403 for a same-origin request under a host Vite would not serve
    ✓ Should accept the host localhost exactly when Vite's own host check does
    ✓ Should accept the host 127.0.0.1 exactly when Vite's own host check does
    ✓ Should accept the host app.localhost exactly when Vite's own host check does
    ✓ Should accept the host attacker.example exactly when Vite's own host check does
    ✓ Should accept the host app-5173.preview.example exactly when Vite's own host check does
    ✓ Should accept the host preview.example exactly when Vite's own host check does
    ✓ Should accept the host notpreview.example exactly when Vite's own host check does
    ✓ Should accept the host [::1] exactly when Vite's own host check does
    ✓ Should accept the host origin.internal exactly when Vite's own host check does
    ✓ Should accept the host sub.origin.internal exactly when Vite's own host check does
Tests:       17 skipped, 15 passed, 32 total

Blast Radius

  • Only the dev server's three /__dd POST routes change. Production builds and the deployed package are unaffected: the guard is used only from configureServer.
  • Every request to those routes now needs Content-Type: application/json.
    • The bundled clients already send it.
    • A manual curl needs -H 'Content-Type: application/json', which the README now shows.
  • A request under a Host that Vite's server.allowedHosts rules reject now gets a 403 from these routes on every supported Vite version. That includes versions older than 5.4.12 or 6.0.9, which had no host check of their own.
  • Risk: low. This is dev-server-only behavior, with no feature flag.
  • Should ship in the next npm release.

Out of Scope / Follow-ups

7 items deferred
Item Status Next step
Rebase against #566 and the #557 stack, which also change createDevServerMiddleware and its dispatch. Expected conflict Whichever merges second rebases; the conflicts are trailing parameters and README sections.
server.allowedHosts: true or server.https turns the host check off, as Vite does; the Origin and content-type checks still apply. Accepted Parity with Vite: HTTPS already blocks DNS rebinding, and a localhost floor would break tunnel and LAN setups that set true.
Vite releases older than 5.4.12 / 6.0.9 have no host check, but these routes now enforce one. Accepted Keep; those releases lack Vite's own host check, and server.allowedHosts still works on them at runtime.
The host check is a port; Vite 7+ uses the host-validation-middleware package for the same rules. Accepted Keep the port: the package covers only part of it, is ESM-only and pre-1.0, and would add a runtime dependency.
A user's own proxy that rewrites Host while keeping the browser Origin gets a 403. Deferred Revisit if a real setup needs it; accepting origins listed explicitly in server.cors.origin is the narrowest option.
The Origin check compares host and port, not scheme. Accepted Each port serves one protocol, and comparing schemes would reject requests through a TLS-terminating proxy.
HTTP/2 (server.https) is covered by unit tests and manual QA, not an automated real-TLS test. Deferred Add one if the repo gains a test certificate helper.

Documentation

🤖 Generated with Claude Code

@tyffical
tyffical requested a balanced review from Copilot October 15, 2026 22:32
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T22:35:26.326404Z 843fe71 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 843fe71769

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-sensitive host-validation port warrants human confirmation across supported Vite versions despite no concrete defects being identified.

0 open findings

What changed in this PR

Hardens the Apps plugin’s three dev-server POST endpoints, which run before Vite’s own request checks.

Changes:

  • Adds Host, Origin, and JSON content-type validation before handler execution.
  • Integrates the guard with resolved Vite configuration.
  • Adds unit and HTTP integration coverage, updates mocks, and documents request requirements.
File Description
packages/​tests/​src/​_jest/​helpers/​mocks.ts Defaults mock requests to same-origin JSON.
packages/​plugins/​apps/​src/​vite/​index.ts Creates and passes the configured host check.
packages/​plugins/​apps/​src/​vite/​index.test.ts Updates fake server configurations.
packages/​plugins/​apps/​src/​vite/​dev-server.ts Guards requests before route dispatch.
packages/​plugins/​apps/​src/​vite/​dev-server.test.ts Tests rejection and allowed preview requests.
packages/​plugins/​apps/​src/​vite/​dev-server.integration.test.ts Adds real-HTTP validation and Vite parity tests.
packages/​plugins/​apps/​src/​vite/​dev-server-request-guard.ts Implements request validation.
packages/​plugins/​apps/​src/​vite/​dev-server-request-guard.test.ts Covers host rules and request decisions.
packages/​plugins/​apps/​README.md Documents endpoint requirements and curl usage.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@tyffical tyffical changed the title [APPS-2953] Fix: check Origin and Host on the apps dev-server routes [APPS-2953] Fix: check Origin, Host and Content-Type on the apps dev-server routes Oct 15, 2026
@tyffical
tyffical marked this pull request as ready for review October 16, 2026 01:13
@tyffical
tyffical requested review from a team as code owners October 16, 2026 01:13
@tyffical
tyffical requested review from Scott-Meyer and removed request for a team October 16, 2026 01:13
…n and allowed hosts

The apps dev-server middleware runs before Vite's CORS and host-check
middleware, so its POST routes (/__dd/debugBundle, /__dd/executeAction,
/__dd/executeActionViaCloud) now check each request themselves:

- the Host must pass the same rules as Vite's server.allowedHosts check,
  built from the resolved Vite config;
- an Origin, when sent, must match the Host;
- the body must be sent as Content-Type: application/json.

Rejected requests get a 403 or 415 before any handler runs.

Refs: APPS-2953

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2953-dev-server-origin-check branch from 843fe71 to a68c95a Compare October 16, 2026 14:31
@tyffical

Copy link
Copy Markdown
Contributor Author

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-10-16 15:36:30 UTC ℹ️ Start processing command /merge


2026-10-16 15:36:36 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 2m (p90).


2026-10-16 15:38:02 UTC ℹ️ MergeQueue: This merge request was merged

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants