open-design/apps/daemon/tests/origin-validation.test.ts
VanJay 369d136d19
Add Docker Compose deployment workflow (#65)
* Add Docker Compose deployment workflow

* Address Docker deployment review feedback

Harden publishing inputs and temporary credential handling, and tighten Docker runtime defaults requested by the PR review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix Docker publish build in CI mode

Set CI=true during the image build so pnpm prune can run non-interactively inside Docker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix Docker runtime dependency layout

Use pnpm deploy for the daemon package so the runtime image includes production dependencies where Node resolves them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Use legacy pnpm deploy in Docker build

Allow pnpm v10 deploy to package the daemon workspace without requiring injected workspace packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Align Docker runtime with Node 24

Use Node 24 for both build and runtime stages and update image verification for the workspace daemon dependency layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove legacy OD_HOST Docker binding fallback

Use OD_BIND_HOST as the single daemon bind-host setting for Docker deployment and origin validation.

* Update Docker image verifier for daemon dist runtime

Check the packaged daemon dist entrypoint and allow npm from the Node 24 runtime image while still rejecting build-only tools.

* Allow private LAN browser origins for daemon

* Share daemon origin validation helpers

Move browser origin validation into a shared daemon module so tests exercise the production logic and cover the remaining private LAN edge cases.

* Harden Docker Compose port exposure

Bind the Compose deployment to localhost by default and pass the published port through to the daemon origin checks so host-port overrides remain same-origin.

* Keep deployment hosts out of local-only no-origin checks

Require an actual matching Origin before configured deployment origins can satisfy local-only daemon guards, preventing no-Origin remote clients from bypassing those checks.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 11:51:51 +08:00

442 lines
14 KiB
TypeScript

// @ts-nocheck
import http from 'node:http';
import express from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
allowedBrowserPorts,
configuredAllowedOrigins,
isAllowedBrowserOrigin,
isLocalSameOrigin,
} from '../src/origin-validation.js';
function createOriginMiddleware(resolvedPort, host = '127.0.0.1') {
const _NULL_ORIGIN_SAFE_GET_RE =
/^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/;
return (req, res, next) => {
const origin = req.headers.origin;
if (origin == null || origin === '') return next();
if (origin === 'null') {
const isSafeReadOnly =
req.method === 'GET' && _NULL_ORIGIN_SAFE_GET_RE.test(req.path);
if (!isSafeReadOnly) {
return res.status(403).json({ error: 'Origin: null not allowed for this route' });
}
return next();
}
if (!resolvedPort) {
return res.status(403).json({ error: 'Server initializing' });
}
const ports = allowedBrowserPorts(resolvedPort);
const extraAllowedOrigins = configuredAllowedOrigins();
if (!isAllowedBrowserOrigin(origin, req.headers.host, ports, host, extraAllowedOrigins)) {
return res.status(403).json({ error: 'Cross-origin requests are not allowed' });
}
next();
};
}
function makeTestApp(port, host = '127.0.0.1') {
const app = express();
app.use(express.json());
app.use('/api', createOriginMiddleware(port, host));
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.get('/api/projects', (_req, res) => res.json({ projects: [] }));
app.post('/api/active', (req, res) => {
if (!isLocalSameOrigin(req, port)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
res.json({ active: true });
});
app.get('/api/projects/:id/raw/:name', (req, res) => {
// Mimics the real raw-file route that sets CORS for Origin: null
if (req.headers.origin === 'null') {
res.header('Access-Control-Allow-Origin', '*');
}
res.json({ file: req.params.name });
});
app.post('/api/projects', (req, res) => res.json({ project: req.body }));
app.delete('/api/projects/:id', (req, res) => res.json({ ok: true }));
app.get('/api/codex-pets/:id/spritesheet', (req, res) => {
// Mimics the real spritesheet route that sets CORS for Origin: null
if (req.headers.origin === 'null') {
res.header('Access-Control-Allow-Origin', 'null');
}
res.type('image/png').send(Buffer.from('fake-sprite'));
});
return app;
}
function request(port, method, path, { origin, headers = {} } = {}) {
return new Promise((resolve) => {
const opts = {
hostname: '127.0.0.1',
port,
path,
method,
headers: {
...headers,
...(origin !== undefined ? { origin } : {}),
},
};
const req = http.request(opts, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
});
req.end();
});
}
describe('daemon origin validation middleware', () => {
let server;
let port;
beforeAll(
() =>
new Promise((resolve) => {
// Start on port 0 to get a dynamic port, then rebuild with real port
const tempApp = makeTestApp(0);
const tempServer = tempApp.listen(0, '127.0.0.1', () => {
port = tempServer.address().port;
tempServer.close(() => {
const realApp = makeTestApp(port);
server = realApp.listen(port, '127.0.0.1', () => resolve());
});
});
}),
);
afterAll(
() =>
new Promise((resolve) => {
server.close(() => resolve());
}),
);
// --- Non-browser clients (no Origin) ---
it('allows requests without Origin header (curl, CLI)', async () => {
const res = await request(port, 'GET', '/api/health');
expect(res.status).toBe(200);
});
// --- Same-origin (localhost) ---
it('allows same-origin requests from http://127.0.0.1', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: `http://127.0.0.1:${port}`,
});
expect(res.status).toBe(200);
});
it('allows same-origin requests from http://localhost', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: `http://localhost:${port}`,
});
expect(res.status).toBe(200);
});
it('allows same-origin requests via HTTPS', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: `https://127.0.0.1:${port}`,
});
expect(res.status).toBe(200);
});
it('allows same-origin requests from a private LAN address', async () => {
const lanHost = `192.168.18.16:${port}`;
const res = await request(port, 'POST', '/api/projects', {
origin: `http://${lanHost}`,
headers: {
Host: lanHost,
'content-type': 'application/json',
},
});
expect(res.status).toBe(200);
});
it.each([
'10.0.5.12',
'172.16.0.1',
'172.31.255.254',
'169.254.10.20',
])('allows same-origin requests from private LAN range %s', async (host) => {
const lanHost = `${host}:${port}`;
const res = await request(port, 'POST', '/api/projects', {
origin: `http://${lanHost}`,
headers: {
Host: lanHost,
'content-type': 'application/json',
},
});
expect(res.status).toBe(200);
});
it.each([
'172.15.255.255',
'172.32.0.1',
'192.168.1.256',
])('blocks non-private or malformed LAN-like address %s', async (host) => {
const lanHost = `${host}:${port}`;
const res = await request(port, 'POST', '/api/projects', {
origin: `http://${lanHost}`,
headers: {
Host: lanHost,
'content-type': 'application/json',
},
});
expect(res.status).toBe(403);
});
it('allows local guarded routes from a matching private LAN origin', async () => {
const lanHost = `192.168.18.16:${port}`;
const res = await request(port, 'POST', '/api/active', {
origin: `http://${lanHost}`,
headers: {
Host: lanHost,
'content-type': 'application/json',
},
});
expect(res.status).toBe(200);
});
it('blocks private LAN origins when the request host differs', async () => {
const res = await request(port, 'POST', '/api/projects', {
origin: `http://192.168.18.16:${port}`,
headers: {
Host: `192.168.18.17:${port}`,
'content-type': 'application/json',
},
});
expect(res.status).toBe(403);
});
it('blocks local guarded routes when the private LAN host differs', async () => {
const res = await request(port, 'POST', '/api/active', {
origin: `http://192.168.18.16:${port}`,
headers: {
Host: `192.168.18.17:${port}`,
'content-type': 'application/json',
},
});
expect(res.status).toBe(403);
});
it('blocks local guarded routes without Origin when Host only matches a configured deployment origin', async () => {
process.env.OD_ALLOWED_ORIGINS = 'https://od.example.com';
try {
const res = await request(port, 'POST', '/api/active', {
headers: {
Host: 'od.example.com',
'content-type': 'application/json',
},
});
expect(res.status).toBe(403);
} finally {
delete process.env.OD_ALLOWED_ORIGINS;
}
});
it('allows local guarded routes from a matching configured deployment origin', async () => {
process.env.OD_ALLOWED_ORIGINS = 'https://od.example.com';
try {
const res = await request(port, 'POST', '/api/active', {
origin: 'https://od.example.com',
headers: {
Host: 'od.example.com',
'content-type': 'application/json',
},
});
expect(res.status).toBe(200);
} finally {
delete process.env.OD_ALLOWED_ORIGINS;
}
});
// --- Origin: null (sandboxed iframe previews) ---
it('allows Origin: null for GET raw-file preview routes', async () => {
const res = await request(port, 'GET', '/api/projects/abc/raw/design.html', {
origin: 'null',
});
expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe('*');
});
it('allows Origin: null for GET codex-pet spritesheet routes', async () => {
const res = await request(port, 'GET', '/api/codex-pets/my-pet/spritesheet', {
origin: 'null',
});
expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe('null');
});
it('rejects Origin: null on POST to state-changing endpoints', async () => {
const res = await request(port, 'POST', '/api/projects', {
origin: 'null',
headers: { 'content-type': 'application/json' },
});
expect(res.status).toBe(403);
expect(JSON.parse(res.body)).toEqual({ error: 'Origin: null not allowed for this route' });
});
it('rejects Origin: null on DELETE endpoints', async () => {
const res = await request(port, 'DELETE', '/api/projects/abc', {
origin: 'null',
});
expect(res.status).toBe(403);
});
it('rejects Origin: null on non-raw-file GET routes', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: 'null',
});
expect(res.status).toBe(403);
});
it('allows explicitly configured deployment origins', async () => {
process.env.OD_ALLOWED_ORIGINS = `https://od.example.com,http://203.0.113.10:${port}`;
try {
const res = await request(port, 'GET', '/api/projects', {
origin: 'https://od.example.com',
});
expect(res.status).toBe(200);
} finally {
delete process.env.OD_ALLOWED_ORIGINS;
}
});
// --- Cross-origin rejection ---
it('blocks cross-origin requests from external domains', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: 'http://evil.com',
});
expect(res.status).toBe(403);
expect(JSON.parse(res.body)).toEqual({ error: 'Cross-origin requests are not allowed' });
});
it('blocks cross-origin requests from other local ports', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: `http://127.0.0.1:9999`,
});
expect(res.status).toBe(403);
});
it('blocks cross-origin POST to state-changing endpoints', async () => {
const res = await request(port, 'POST', '/api/projects', {
origin: 'http://attacker.local',
headers: { 'content-type': 'application/json' },
});
expect(res.status).toBe(403);
});
// --- OD_WEB_PORT (split-port proxy) ---
it('allows requests from OD_WEB_PORT (web proxy port)', async () => {
const webPort = port + 1000;
process.env.OD_WEB_PORT = String(webPort);
const res = await request(port, 'GET', '/api/projects', {
origin: `http://127.0.0.1:${webPort}`,
});
delete process.env.OD_WEB_PORT;
expect(res.status).toBe(200);
});
it('blocks requests from unknown ports even with OD_WEB_PORT set', async () => {
const webPort = port + 1000;
process.env.OD_WEB_PORT = String(webPort);
const res = await request(port, 'GET', '/api/projects', {
origin: `http://127.0.0.1:${port + 2000}`,
});
delete process.env.OD_WEB_PORT;
expect(res.status).toBe(403);
});
// Note: fail-closed coverage when port=0 is tested in the dedicated
// describe block below ("fail-closed before port resolution").
});
describe('origin validation: fail-closed before port resolution', () => {
let server;
let port;
beforeAll(
() =>
new Promise((resolve) => {
const app = makeTestApp(0); // port=0 → not resolved
server = app.listen(0, '127.0.0.1', () => {
port = server.address().port;
resolve();
});
}),
);
afterAll(
() =>
new Promise((resolve) => {
server.close(() => resolve());
}),
);
it('blocks browser origins when port is not resolved (fail-closed)', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: `http://127.0.0.1:${port}`,
});
expect(res.status).toBe(403);
});
it('still allows non-browser clients when port is not resolved', async () => {
const res = await request(port, 'GET', '/api/health');
expect(res.status).toBe(200);
});
});
describe('origin validation: non-loopback bind host', () => {
let server;
let port;
const nonLoopbackHost = '100.64.1.2'; // Tailscale-like address
beforeAll(
() =>
new Promise((resolve) => {
// Start on port 0 to get a dynamic port, then rebuild with real port
const tempApp = makeTestApp(0, nonLoopbackHost);
const tempServer = tempApp.listen(0, '127.0.0.1', () => {
port = tempServer.address().port;
tempServer.close(() => {
const realApp = makeTestApp(port, nonLoopbackHost);
server = realApp.listen(port, '127.0.0.1', () => resolve());
});
});
}),
);
afterAll(
() =>
new Promise((resolve) => {
server.close(() => resolve());
}),
);
it('allows browser requests from the non-loopback bind host', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: `http://${nonLoopbackHost}:${port}`,
});
expect(res.status).toBe(200);
});
it('still allows localhost origins alongside non-loopback host', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: `http://127.0.0.1:${port}`,
});
expect(res.status).toBe(200);
});
it('blocks unknown external origins even with non-loopback host', async () => {
const res = await request(port, 'GET', '/api/projects', {
origin: `http://evil.com:${port}`,
});
expect(res.status).toBe(403);
});
});