mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
An in-container hook curl carries Host: host.docker.internal:<port> (the derived CODEMAN_API_URL), so the always-on host guard must allow host.docker.internal / host.containers.internal or every in-container hook is blocked 403. Exact-match only; not a browser DNS-rebinding surface (resolves to the host only from inside a container netns). Verified end-to-end: quick-start launches claude/shell in a real container with the workspace bind-mounted and hooks scaffolded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
6.2 KiB
TypeScript
153 lines
6.2 KiB
TypeScript
import { isIP } from 'node:net';
|
|
|
|
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
|
|
export function isExplicitlyEnabled(value: string | undefined): boolean {
|
|
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* True when unauthenticated network exposure is acceptable: either a password is
|
|
* set (auth active) or the operator explicitly acknowledged it. Used by the
|
|
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
|
|
*/
|
|
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
|
|
if (process.env.CODEMAN_PASSWORD) return true;
|
|
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
|
|
}
|
|
|
|
export function isLoopbackBindHost(host: string): boolean {
|
|
const normalized = host
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^\[(.*)\]$/, '$1');
|
|
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
|
|
return true;
|
|
}
|
|
if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
|
|
return true;
|
|
}
|
|
return normalized.startsWith('::ffff:127.');
|
|
}
|
|
|
|
/**
|
|
* Hostname suffixes that are always accepted by the Host/Origin allowlist. These
|
|
* are namespaces an external attacker cannot register DNS-rebinding records under
|
|
* (tailscale MagicDNS, Cloudflare quick/named tunnels), so accepting them keeps
|
|
* the project's documented tunnel access paths working without reopening the
|
|
* rebinding hole. Extend per-deployment via CODEMAN_ALLOWED_HOSTS.
|
|
*/
|
|
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
|
|
|
|
/**
|
|
* Container-to-host gateway aliases (Docker / Podman). A hook `curl` from INSIDE a
|
|
* docker case carries `Host: host.docker.internal:<port>` (the derived
|
|
* CODEMAN_API_URL), so the always-on host guard must allow it or every in-container
|
|
* hook is blocked 403. These names only resolve to the host from within a
|
|
* container's network namespace, so they are not a DNS-rebinding surface for a
|
|
* normal browser. Both engines' aliases are allowed so a mixed fleet keeps working.
|
|
*/
|
|
export const DOCKER_HOST_GATEWAY_ALIASES = ['host.docker.internal', 'host.containers.internal'];
|
|
|
|
/** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */
|
|
export interface HostPolicy {
|
|
/** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */
|
|
bindHost: string;
|
|
/** Extra allowed hosts: exact lowercased names, or a leading-dot '.suffix' for suffix matches. */
|
|
allowedHosts: string[];
|
|
/** Hostname of the currently-active Codeman-managed tunnel, if any. */
|
|
tunnelHost?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Extract the lowercased hostname from a Host/authority value, stripping the port
|
|
* and IPv6 brackets. Returns null for empty/garbage input.
|
|
*/
|
|
export function parseAuthorityHostname(authority: string | undefined): string | null {
|
|
if (!authority) return null;
|
|
let h = authority.trim();
|
|
if (!h) return null;
|
|
if (h.startsWith('[')) {
|
|
// [::1] or [::1]:3000
|
|
const end = h.indexOf(']');
|
|
if (end === -1) return null;
|
|
return h.slice(1, end).toLowerCase() || null;
|
|
}
|
|
// host:port — only treat a single trailing colon as a port separator so a
|
|
// bracketless IPv6 literal (multiple colons) is left intact.
|
|
const first = h.indexOf(':');
|
|
if (first !== -1 && first === h.lastIndexOf(':')) {
|
|
h = h.slice(0, first);
|
|
}
|
|
return h.toLowerCase() || null;
|
|
}
|
|
|
|
/** Build a HostPolicy from the bind host, CODEMAN_ALLOWED_HOSTS, and an active tunnel URL. */
|
|
export function buildHostPolicy(bindHost: string, tunnelUrl?: string | null): HostPolicy {
|
|
const allowedHosts = (process.env.CODEMAN_ALLOWED_HOSTS || '')
|
|
.split(',')
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
let tunnelHost: string | null = null;
|
|
if (tunnelUrl) {
|
|
try {
|
|
tunnelHost = new URL(tunnelUrl).hostname.toLowerCase();
|
|
} catch {
|
|
tunnelHost = null;
|
|
}
|
|
}
|
|
return { bindHost, allowedHosts, tunnelHost };
|
|
}
|
|
|
|
function matchesHost(hostname: string, policy: HostPolicy): boolean {
|
|
// localhost is reserved (always resolves to loopback, not rebindable).
|
|
if (hostname === 'localhost') return true;
|
|
// Any IP literal: a literal address cannot be the target of DNS rebinding — the
|
|
// browser connected straight to it, there is no name to re-point.
|
|
if (isIP(hostname) !== 0) return true;
|
|
const bind = parseAuthorityHostname(policy.bindHost);
|
|
if (bind && hostname === bind) return true;
|
|
if (policy.tunnelHost && hostname === policy.tunnelHost) return true;
|
|
// Docker/Podman container-to-host gateway aliases (for in-container hook curls).
|
|
if (DOCKER_HOST_GATEWAY_ALIASES.includes(hostname)) return true;
|
|
for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
|
|
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true;
|
|
}
|
|
for (const entry of policy.allowedHosts) {
|
|
if (entry.startsWith('.')) {
|
|
if (hostname === entry.slice(1) || hostname.endsWith(entry)) return true;
|
|
} else if (hostname === entry) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* True if a request's Host header is allowed. Blocks DNS-rebinding: a custom
|
|
* domain rebound to a loopback/LAN address still carries its own name in Host,
|
|
* which will not be in the allowlist.
|
|
*/
|
|
export function isAllowedRequestHost(hostHeader: string | undefined, policy: HostPolicy): boolean {
|
|
const hostname = parseAuthorityHostname(hostHeader);
|
|
if (!hostname) return false;
|
|
return matchesHost(hostname, policy);
|
|
}
|
|
|
|
/**
|
|
* True if a request's Origin is allowed for a state-changing / WebSocket request.
|
|
* A MISSING Origin is allowed: non-browser clients (curl, Claude Code hooks) omit
|
|
* it, while browsers always attach it on cross-origin state-changing/WS requests —
|
|
* so a forged cross-site request is caught while local automation keeps working.
|
|
* The opaque origin 'null' (sandboxed iframe, data: URL) is rejected.
|
|
*/
|
|
export function isAllowedRequestOrigin(originHeader: string | undefined, policy: HostPolicy): boolean {
|
|
if (originHeader === undefined || originHeader === '') return true;
|
|
if (originHeader === 'null') return false;
|
|
try {
|
|
return matchesHost(new URL(originHeader).hostname.toLowerCase(), policy);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|