Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cc7d4ee
Add Docker Compose deployment workflow
wangwanjie Apr 29, 2026
c4d5fe7
Address Docker deployment review feedback
wangwanjie Apr 30, 2026
aee433e
Merge main into Docker Compose deployment branch
wangwanjie Apr 30, 2026
1d20f4d
Fix Docker publish build in CI mode
wangwanjie Apr 30, 2026
cddb79d
Fix Docker runtime dependency layout
wangwanjie Apr 30, 2026
6fb7daa
Use legacy pnpm deploy in Docker build
wangwanjie Apr 30, 2026
075dd5c
Align Docker runtime with Node 24
wangwanjie Apr 30, 2026
96b9a38
Merge remote-tracking branch 'gh_origin/main' into feature/docker-com…
wangwanjie Apr 30, 2026
6a23252
Merge gh_origin/main and update Docker packaging
wangwanjie May 5, 2026
481f74f
Remove legacy OD_HOST Docker binding fallback
wangwanjie May 5, 2026
c091230
Update Docker image verifier for daemon dist runtime
wangwanjie May 5, 2026
a6936a1
Allow private LAN browser origins for daemon
wangwanjie May 5, 2026
4472f8b
Share daemon origin validation helpers
wangwanjie May 6, 2026
157deb9
Harden Docker Compose port exposure
wangwanjie May 6, 2026
f7a4eda
Merge remote-tracking branch 'gh_origin/main' into feature/docker-com…
wangwanjie May 6, 2026
ae14186
Merge remote-tracking branch 'gh_origin/main' into feature/docker-com…
wangwanjie May 7, 2026
34617bc
Merge remote-tracking branch 'origin/main' into feature/docker-compos…
mrcfps May 8, 2026
5bd9126
Keep deployment hosts out of local-only no-origin checks
wangwanjie May 8, 2026
abb0777
Merge remote-tracking branch 'origin/main' into pr65-merge-fix
lefarcen May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.git
.github
.DS_Store
.claude-sessions
.cursor
.task
.od
.ocd
node_modules
dist
coverage
*.log
tsconfig.tsbuildinfo
deploy/*.env
deploy/.env
deploy/**/*.tar
deploy/**/*.tgz
deploy/**/*.zip
docs
story
167 changes: 167 additions & 0 deletions apps/daemon/src/origin-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
export interface ParsedHostHeader {
hostname: string;
host: string;
port: string;
}

export interface RequestWithOriginHeaders {
headers?: {
host?: unknown;
origin?: unknown;
};
}

export function configuredAllowedOrigins(env: NodeJS.ProcessEnv = process.env): string[] {
const raw = env.OD_ALLOWED_ORIGINS || '';
if (!raw.trim()) return [];
return raw
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
.map((origin) => {
const parsed = new URL(origin);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('OD_ALLOWED_ORIGINS only supports http:// and https:// origins');
}
return parsed.origin;
});
}

export function configuredAllowedHosts(origins = configuredAllowedOrigins()): string[] {
return origins.map((origin) => new URL(origin).host);
}

export function allowedBrowserPorts(
port: number | string | null | undefined,
env: NodeJS.ProcessEnv = process.env,
): number[] {
const ports = [];
const primary = Number(port);
if (primary) ports.push(primary);
const webPort = Number(env.OD_WEB_PORT);
if (webPort && webPort !== primary) ports.push(webPort);
return ports;
}

export function parseHostHeader(value: unknown): ParsedHostHeader | null {
const raw = String(headerValue(value) || '').trim();
if (!raw) return null;
try {
const parsed = new URL(`http://${raw}`);
return { hostname: parsed.hostname, host: parsed.host, port: parsed.port || '80' };
} catch {
return null;
}
}

export function isPrivateIpv4(hostname: unknown): boolean {
const parts = String(hostname || '').split('.');
if (parts.length !== 4) return false;
if (!parts.every((part) => /^\d+$/.test(part))) return false;
const octets = parts.map((part) => Number(part));
if (!octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) return false;
const [a, b] = octets as [number, number, number, number];
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
);
}

export function isLoopbackOrPrivateLanHost(hostname: unknown): boolean {
const host = String(hostname || '').toLowerCase();
return (
host === 'localhost' ||
host === '127.0.0.1' ||
host === '::1' ||
host === '[::1]' ||
host === '0.0.0.0' ||
host === '::' ||
isPrivateIpv4(host)
);
}

export function isAllowedBrowserHost(
hostHeader: unknown,
ports: number[],
bindHost: string,
extraAllowedOrigins: string[],
): boolean {
const requestHost = parseHostHeader(hostHeader);
if (!requestHost) return false;

const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]'];
const explicitHosts = new Set([
...ports.flatMap((p) => [
...loopbackHosts.map((h) => `${h}:${p}`),
`${bindHost}:${p}`,
]),
...configuredAllowedHosts(extraAllowedOrigins),
]);
if (explicitHosts.has(requestHost.host)) return true;

if (!ports.map(String).includes(requestHost.port)) return false;
return isLoopbackOrPrivateLanHost(requestHost.hostname);
}

export function isAllowedBrowserOrigin(
origin: unknown,
hostHeader: unknown,
ports: number[],
bindHost: string,
extraAllowedOrigins: string[],
): boolean {
if (extraAllowedOrigins.includes(String(origin))) return true;

let parsedOrigin;
try {
parsedOrigin = new URL(String(origin));
} catch {
return false;
}
if (parsedOrigin.protocol !== 'http:' && parsedOrigin.protocol !== 'https:') return false;

const requestHost = parseHostHeader(hostHeader);
if (!requestHost) return false;

const schemes = ['http', 'https'];
const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]'];
const explicitOrigins = new Set(
ports.flatMap((p) => [
...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)),
...schemes.map((s) => `${s}://${bindHost}:${p}`),
]),
);
if (explicitOrigins.has(String(origin))) return true;

const originPort = parsedOrigin.port || (parsedOrigin.protocol === 'https:' ? '443' : '80');
if (!ports.map(String).includes(originPort)) return false;
if (parsedOrigin.hostname !== requestHost.hostname) return false;
return isLoopbackOrPrivateLanHost(parsedOrigin.hostname);
}

export function isLocalSameOrigin(
req: RequestWithOriginHeaders,
port: number | string | null | undefined,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const host = String(headerValue(req.headers?.host) || '');
const origin = headerValue(req.headers?.origin);
const ports = allowedBrowserPorts(port, env);
const bindHost = env.OD_BIND_HOST || '127.0.0.1';
const extraAllowedOrigins = configuredAllowedOrigins(env);

const localHostAllowed = isAllowedBrowserHost(host, ports, bindHost, []);
if (origin == null || origin === '') return localHostAllowed;
if (!isAllowedBrowserHost(host, ports, bindHost, extraAllowedOrigins)) return false;
return isAllowedBrowserOrigin(origin, host, ports, bindHost, extraAllowedOrigins);
}

function headerValue(value: unknown): string | undefined {
if (Array.isArray(value)) {
const first = value[0];
return first == null ? undefined : String(first);
}
return value == null ? undefined : String(value);
}
82 changes: 11 additions & 71 deletions apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ import {
VERCEL_PROVIDER_ID,
writeDeployConfig,
} from './deploy.js';
import {
allowedBrowserPorts,
configuredAllowedOrigins,
isAllowedBrowserOrigin,
isLocalSameOrigin,
} from './origin-validation.js';

/** @typedef {import('@open-design/contracts').ApiErrorCode} ApiErrorCode */
/** @typedef {import('@open-design/contracts').ApiError} ApiError */
Expand Down Expand Up @@ -1517,36 +1523,13 @@ export function createSseResponse(

export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '127.0.0.1', returnServer = false } = {}) {
let resolvedPort = port;
const extraAllowedOrigins = configuredAllowedOrigins();
const app = express();
app.use(express.json({ limit: '4mb' }));

// Build the set of allowed browser origins for the current bind config.
// Shared by the global origin middleware and isLocalSameOrigin() so
// both use the same policy (loopback + explicit bind host, HTTP + HTTPS,
// OD_WEB_PORT support).
function buildAllowedOrigins() {
const ports = [resolvedPort];
const webPort = Number(process.env.OD_WEB_PORT);
if (webPort && webPort !== resolvedPort) ports.push(webPort);
const schemes = ['http', 'https'];
const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]'];
return new Set(
ports.flatMap((p) => [
...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)),
// When bound to a specific non-loopback address (e.g. Tailscale,
// LAN IP, or 0.0.0.0), allow browser requests from that address
// too so the documented --host escape hatch remains usable.
...schemes.map((s) => `${s}://${host}:${p}`),
]),
);
}

// Portless loopback origins (e.g. http://127.0.0.1 without a port).
// Chrome may strip the port from the Origin header on same-origin GET
// requests. Only used as a fallback for safe, idempotent GET requests;
// mutating routes (POST/PUT/PATCH/DELETE) always require an exact
// port-match via buildAllowedOrigins() or isLocalSameOrigin() to
// prevent local CSRF from a page on the default port (80).
// requests. Only use this as a fallback for safe, idempotent GET requests;
// mutating routes always require an exact origin/host match.
function isPortlessLoopbackOrigin(origin) {
return /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])$/.test(origin);
}
Expand Down Expand Up @@ -1585,14 +1568,8 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
return res.status(403).json({ error: 'Server initializing' });
}

if (!buildAllowedOrigins().has(String(origin))) {
// Fallback: Chrome may strip the port from the Origin header on
// same-origin requests (e.g. http://127.0.0.1 instead of
// http://127.0.0.1:6313). Allow portless loopback origins only
// for GET requests, which are idempotent and safe from CSRF.
// Mutating methods (POST/PUT/PATCH/DELETE) always require an
// exact port-match to prevent a page on the default port (80)
// from triggering state-changing operations.
const ports = allowedBrowserPorts(resolvedPort);
if (!isAllowedBrowserOrigin(origin, req.headers.host, ports, host, extraAllowedOrigins)) {
if (req.method !== 'GET' || !isPortlessLoopbackOrigin(String(origin))) {
return res.status(403).json({ error: 'Cross-origin requests are not allowed' });
}
Expand Down Expand Up @@ -5727,40 +5704,3 @@ export function rewriteSkillAssetUrls(html: string, skillId: string): string {
},
);
}

export function isLocalSameOrigin(req, port) {
// Accepts http + https, loopback hosts, OD_WEB_PORT, and the explicit
// bind host — matching the global origin middleware policy exactly.
const host = String(req.headers.host || '');
const origin = req.headers.origin;

// Build allowed set inline (same logic as buildAllowedOrigins in
// startServer, but self-contained so the exported helper works
// without closing over server-scoped variables).
const ports = [port];
const webPort = Number(process.env.OD_WEB_PORT);
if (webPort && webPort !== port) ports.push(webPort);
const bindHost = process.env.OD_BIND_HOST || '127.0.0.1';
const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]'];
const allowedHosts = new Set(
ports.flatMap((p) => [
...loopbackHosts.map((h) => `${h}:${p}`),
`${bindHost}:${p}`,
]),
);

// Reject unknown Host first (DNS rebinding / Host header attack)
if (!allowedHosts.has(host)) return false;

// Non-browser client with valid Host → allow
if (origin == null || origin === '') return true;

const schemes = ['http', 'https'];
const allowedOrigins = new Set(
ports.flatMap((p) => [
...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)),
...schemes.map((s) => `${s}://${bindHost}:${p}`),
]),
);
return allowedOrigins.has(String(origin));
}
14 changes: 13 additions & 1 deletion apps/daemon/tests/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from 'vitest';

import { readAppConfig, writeAppConfig } from '../src/app-config.js';
import { isLocalSameOrigin } from '../src/server.js';
import { isLocalSameOrigin } from '../src/origin-validation.js';

describe('app-config', () => {
let dataDir: string;
Expand Down Expand Up @@ -420,6 +420,18 @@ describe('app-config origin guard', () => {
expect(res.status).toBe(403);
});

it('rejects no-Origin requests that only match configured deployment hosts', async () => {
process.env.OD_ALLOWED_ORIGINS = 'https://od.example.com';
try {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: { Host: 'od.example.com' },
});
expect(res.status).toBe(403);
} finally {
delete process.env.OD_ALLOWED_ORIGINS;
}
});

it('still rejects non-loopback Origin', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Expand Down
21 changes: 19 additions & 2 deletions apps/daemon/tests/mcp-install-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import express from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
import { isLocalSameOrigin } from '../src/server.js';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { isLocalSameOrigin } from '../src/origin-validation.js';
import { buildMcpInstallPayload } from '../src/mcp-install-info.js';

// The install-info endpoint is a self-contained handler that resolves
Expand Down Expand Up @@ -152,6 +152,11 @@ describe('GET /api/mcp/install-info', () => {
}),
);

afterEach(() => {
delete process.env.OD_ALLOWED_ORIGINS;
delete process.env.OD_BIND_HOST;
});

it('non-sidecar launch bakes --daemon-url so custom ports keep working', async () => {
const { port } = nonSidecar;
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`);
Expand Down Expand Up @@ -202,6 +207,18 @@ describe('GET /api/mcp/install-info', () => {
expect(res.status).toBe(200);
});

it('accepts explicitly configured deployment origins', async () => {
const { port } = nonSidecar;
process.env.OD_ALLOWED_ORIGINS = `https://od.example.com,http://203.0.113.10:${port}`;
const res = await fetch(`http://127.0.0.1:${port}/api/mcp/install-info`, {
headers: {
Host: 'od.example.com',
Origin: 'https://od.example.com',
},
});
expect(res.status).toBe(200);
});

it('caches the payload across rapid calls', async () => {
const { port, app } = nonSidecar;
const before = (app as any)._resolveCalls();
Expand Down
Loading
Loading