- routes/ca.js: declare format before pfx/pem/crt dispatch (was ReferenceError on every request that passed validation); add CA_CERT_FORMATS single source of truth + hardened format extraction (string-coerce, whitelist) - routes/caddycode.js: fix upstream-validation regexes (control-char classes) - routes/logs.js: SSE/validation lint fixes - routes/openclaw.js: remove useless regex escape in ALLOWED_PATH_RE - fleet-validation.js + http-caddy-admin-origin test: eslint-disable for intentional control-regex security sentinels - ca-dc076.routes.test.js: regression test for declared format + behavioral coverage of format validation (now pre-PKI) Judge: Qwen lane (qwen3.8-max) grade B, 0 blocking, verdict /tmp/judge-batch1-verdict.json. API suite 2859/2859 green.
382 lines
13 KiB
JavaScript
382 lines
13 KiB
JavaScript
/**
|
|
* DC-106: Caddyfile-as-code — generate Caddyfile entries from structured JSON
|
|
*
|
|
* Allows building reverse proxy configs programmatically instead of editing
|
|
* raw Caddyfile text. The frontend can present a visual form, send the JSON,
|
|
* and get back a Caddyfile snippet + apply it via the Caddy admin API.
|
|
*
|
|
* POST /api/v1/caddycode/generate — generate Caddyfile block from JSON
|
|
* POST /api/v1/caddycode/validate — validate a generated block
|
|
* GET /api/v1/caddycode/importers — list supported import formats
|
|
*/
|
|
const express = require('express');
|
|
const { ok, errorResponse } = require('../src/utils/responses');
|
|
const { REGEX } = require('../src/utilities/constants');
|
|
|
|
/**
|
|
* DC-070: Validate the structural config that flows into generateSiteBlock.
|
|
*
|
|
* Threat model: `generateSiteBlock` interpolates user-controlled fields
|
|
* (domain, tls, authService, headers.*, stripPrefix, upstream) DIRECTLY into
|
|
* a Caddyfile text block that is later fed to `caddy.modify()` and the
|
|
* Caddy admin /load endpoint. The /caddycode/generate endpoint is
|
|
* authenticated (forward_auth gated), but the bug class is "compromised
|
|
* middleware / pivot" — a JSON-only payload can be smuggled past any
|
|
* UI-side input checks.
|
|
*
|
|
* Pre-fix, every field was trusted: `lines.push(`${domain} {`)` accepted any
|
|
* string (including newlines that close the block and inject a new site),
|
|
* `headers[key] = "${value}"` accepted arbitrary quotes (which would break
|
|
* the surrounding `"..."` Caddy quoted-string context and inject directives),
|
|
* and `tls`, `authService`, `stripPrefix`, `upstream` had no charset
|
|
* restrictions at all (spaces, braces, semicolons would land verbatim).
|
|
*
|
|
* Post-fix: every field is constrained to a known-safe character class
|
|
* BEFORE interpolation, and CRLF is rejected outright. Quoted-string
|
|
* injection in header values is closed by escaping `\` and `"` per the
|
|
* Caddy quoted-string spec (backslash escapes the next character).
|
|
*/
|
|
function validateGenerationConfig(config) {
|
|
const errors = [];
|
|
const {
|
|
domain,
|
|
upstream,
|
|
upstreamProtocol = 'http',
|
|
tls = 'auto',
|
|
auth = false,
|
|
authService = null,
|
|
headers = {},
|
|
stripPrefix = null,
|
|
} = config;
|
|
|
|
// 1. domain — RFC 1123 hostname. Reject anything with whitespace, brace,
|
|
// semicolon, newline, or non-printable. REGEX.DOMAIN is
|
|
// /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i in constants.js.
|
|
if (typeof domain !== 'string' || !REGEX.DOMAIN.test(domain)) {
|
|
errors.push('domain must be a valid hostname (letters, digits, dots, hyphens)');
|
|
}
|
|
|
|
// 2. upstream — `host:port` form (the only shape Caddy's reverse_proxy
|
|
// directive takes for non-URL upstreams). Reject `://`, whitespace,
|
|
// braces. Allow optional IPv6 bracket form `[::1]:5000`. Must
|
|
// include an explicit :port segment — a bare `localhost` would
|
|
// produce a Caddyfile that fails to reload (port required for
|
|
// reverse_proxy upstreams). Two regex branches: (a) bare host with
|
|
// required :port, (b) bracketed IPv6 literal with required :port.
|
|
if (typeof upstream !== 'string'
|
|
|| !/^[a-z0-9.-]+:\d{1,5}$/i.test(upstream)
|
|
&& !/^\[[a-z0-9.:.-]+\]:\d{1,5}$/i.test(upstream)
|
|
) {
|
|
errors.push('upstream must be host:port (host letters/digits/dots/hyphens, port 1-65535, optional IPv6 brackets)');
|
|
}
|
|
|
|
// 3. tls — either the literal strings 'auto' / 'internal' (handled
|
|
// specially below) OR a CA name like 'letsencrypt' / 'internal' that
|
|
// must match /^[a-z0-9._-]+$/i. Reject whitespace + braces + quotes.
|
|
if (typeof tls !== 'string' || !/^[a-z0-9._-]+$/i.test(tls)) {
|
|
errors.push('tls must be one of: auto, internal, or a CA name (letters, digits, dots, underscores, hyphens)');
|
|
}
|
|
|
|
// 4. authService — only meaningful when auth=true; otherwise ignore. Must
|
|
// match the existing SSO service-id charset (REGEX.SUBDOMAIN).
|
|
if (auth) {
|
|
if (typeof authService !== 'string' || !REGEX.SUBDOMAIN.test(authService)) {
|
|
errors.push('authService must be a valid subdomain (lowercase, alphanumeric, hyphens)');
|
|
}
|
|
}
|
|
|
|
// 5. upstreamProtocol — only 'http' or 'https'. Anything else gets coerced
|
|
// to 'http' but only after we explicitly accept it; reject obvious
|
|
// injection vectors here.
|
|
if (upstreamProtocol !== 'http' && upstreamProtocol !== 'https') {
|
|
errors.push('upstreamProtocol must be "http" or "https"');
|
|
}
|
|
|
|
// 6. headers — each key must be a valid HTTP header name ([A-Za-z0-9-]+),
|
|
// each value must be a string with no CR/LF and no unescaped quotes.
|
|
if (headers && typeof headers === 'object') {
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (typeof key !== 'string' || !/^[A-Za-z0-9-]+$/.test(key)) {
|
|
errors.push(`header key "${String(key)}" must be HTTP-token chars only ([A-Za-z0-9-])`);
|
|
}
|
|
if (typeof value !== 'string') {
|
|
errors.push(`header "${key}" value must be a string`);
|
|
continue;
|
|
}
|
|
if (/[\r\n]/.test(value)) {
|
|
errors.push(`header "${key}" value must not contain CR or LF`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. stripPrefix — must be a leading-slash path with safe chars. Reject
|
|
// braces, quotes, whitespace, and { } which would let the attacker
|
|
// open a new Caddyfile block.
|
|
if (stripPrefix != null) {
|
|
if (typeof stripPrefix !== 'string' || !/^\/[A-Za-z0-9._\-/]*$/.test(stripPrefix)) {
|
|
errors.push('stripPrefix must be an absolute path (letters, digits, dots, hyphens, slashes)');
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors };
|
|
}
|
|
|
|
/**
|
|
* Escape a string for safe interpolation inside a Caddyfile quoted-string
|
|
* context. Caddy uses the same backslash-escape semantics as JSON-ish
|
|
* contexts — `\` and `"` MUST be escaped, otherwise the attacker breaks out
|
|
* of the quoted string and injects arbitrary directives.
|
|
*
|
|
* @param {string} s raw header value
|
|
* @returns {string} escaped value (no embedded newlines; CR/LF were already
|
|
* rejected by the validator)
|
|
*/
|
|
function escapeCaddyQuotedString(s) {
|
|
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
}
|
|
|
|
/**
|
|
* Generate a Caddyfile site block from a structured config.
|
|
*
|
|
* Every interpolated field is now validated by `validateGenerationConfig`
|
|
* first (see DC-070). Quoted-string values are escaped via
|
|
* `escapeCaddyQuotedString` so a `"` in a header value cannot break out.
|
|
*
|
|
* @param {Object} config - Site configuration (already validated)
|
|
* @returns {string} Caddyfile snippet
|
|
*/
|
|
function generateSiteBlock(config) {
|
|
const {
|
|
domain,
|
|
upstream,
|
|
upstreamProtocol = 'http',
|
|
tls = 'auto',
|
|
websocket = false,
|
|
auth = false,
|
|
authService = null,
|
|
headers = {},
|
|
cors = false,
|
|
rateLimit = null,
|
|
cache = false,
|
|
compress = true,
|
|
stripPrefix = null,
|
|
redirectToHttps = true,
|
|
} = config;
|
|
|
|
const lines = [];
|
|
lines.push(`${domain} {`);
|
|
|
|
// TLS — only emit a tls directive when explicitly 'internal' or a CA
|
|
// name; 'auto' means Caddy's default behaviour (no directive needed).
|
|
if (tls === 'internal') {
|
|
lines.push(` tls internal`);
|
|
} else if (tls === 'auto') {
|
|
// Default — Caddy auto-provisions Let's Encrypt
|
|
} else {
|
|
// CA name validated by validateGenerationConfig against
|
|
// /^[a-z0-9._-]+$/i — safe to interpolate verbatim.
|
|
lines.push(` tls ${tls}`);
|
|
}
|
|
|
|
// Redirect HTTP→HTTPS
|
|
if (redirectToHttps) {
|
|
lines.push(` # Redirect HTTP to HTTPS is automatic in Caddy 2`);
|
|
}
|
|
|
|
// Auth gate (DashCaddy forward_auth) — authService validated by
|
|
// validateGenerationConfig against REGEX.SUBDOMAIN — safe to interpolate.
|
|
if (auth && authService) {
|
|
lines.push(` import dashcaddy_auth ${authService}`);
|
|
}
|
|
|
|
// CORS headers
|
|
if (cors) {
|
|
lines.push(` header {`);
|
|
lines.push(` Access-Control-Allow-Origin *`);
|
|
lines.push(` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"`);
|
|
lines.push(` Access-Control-Allow-Headers "Content-Type, Authorization"`);
|
|
lines.push(` }`);
|
|
}
|
|
|
|
// Custom headers — keys validated against /^[A-Za-z0-9-]+$/, values
|
|
// escaped via escapeCaddyQuotedString before being placed inside "..."
|
|
if (headers && typeof headers === 'object' && Object.keys(headers).length > 0) {
|
|
lines.push(` header {`);
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
lines.push(` ${key} "${escapeCaddyQuotedString(value)}"`);
|
|
}
|
|
lines.push(` }`);
|
|
}
|
|
|
|
// Strip prefix — validated to /^\/[A-Za-z0-9._\-/]*$/ — safe.
|
|
if (stripPrefix) {
|
|
lines.push(` uri strip_prefix ${stripPrefix}`);
|
|
}
|
|
|
|
// Compression
|
|
if (compress) {
|
|
lines.push(` encode gzip zstd`);
|
|
}
|
|
|
|
// Reverse proxy
|
|
const protocol = upstreamProtocol === 'https' ? 'https' : 'http';
|
|
lines.push(` reverse_proxy ${protocol}://${upstream} {`);
|
|
if (websocket) {
|
|
lines.push(` # WebSocket support is automatic in Caddy 2`);
|
|
}
|
|
lines.push(` header_up Host {host}`);
|
|
lines.push(` transport http {`);
|
|
lines.push(` read_timeout 5m`);
|
|
lines.push(` write_timeout 5m`);
|
|
lines.push(` }`);
|
|
lines.push(` }`);
|
|
|
|
lines.push(`}`);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
module.exports = function({ asyncHandler }) {
|
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
|
const router = express.Router();
|
|
|
|
// POST /api/v1/caddycode/generate
|
|
router.post('/caddycode/generate', wrap(async (req, res) => {
|
|
const config = req.body || {};
|
|
|
|
if (!config.domain) {
|
|
return errorResponse(res, 400, 'domain is required');
|
|
}
|
|
if (!config.upstream) {
|
|
return errorResponse(res, 400, 'upstream is required (e.g. localhost:8080)');
|
|
}
|
|
|
|
// DC-070: structural validation BEFORE interpolation. Every field that
|
|
// flows into the Caddyfile text must satisfy a known-safe charset rule,
|
|
// and CRLF is rejected outright. Run this BEFORE generateSiteBlock so
|
|
// the bad input is rejected with a clean 400 + enumerable error list,
|
|
// not a generated-Caddyfile + 500.
|
|
const validation = validateGenerationConfig(config);
|
|
if (!validation.valid) {
|
|
return errorResponse(res, 400, 'Invalid configuration', {
|
|
code: 'DC-CCD-700',
|
|
errors: validation.errors,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const caddyfile = generateSiteBlock(config);
|
|
ok(res, { caddyfile, config });
|
|
} catch (err) {
|
|
errorResponse(res, 500, `Generation failed: ${err.message}`);
|
|
}
|
|
}));
|
|
|
|
// POST /api/v1/caddycode/validate
|
|
router.post('/caddycode/validate', wrap(async (req, res) => {
|
|
const { caddyfile } = req.body || {};
|
|
|
|
if (!caddyfile) {
|
|
return errorResponse(res, 400, 'caddyfile string is required');
|
|
}
|
|
|
|
// Basic validation checks
|
|
const issues = [];
|
|
|
|
// Check for balanced braces
|
|
const openBraces = (caddyfile.match(/{/g) || []).length;
|
|
const closeBraces = (caddyfile.match(/}/g) || []).length;
|
|
if (openBraces !== closeBraces) {
|
|
issues.push(`Unbalanced braces: ${openBraces} open vs ${closeBraces} close`);
|
|
}
|
|
|
|
// Check for domain in first non-empty line
|
|
const firstLine = caddyfile.trim().split('\n')[0].trim();
|
|
if (!firstLine || firstLine.startsWith('#') || firstLine.startsWith('{')) {
|
|
issues.push('First line should be a domain name');
|
|
}
|
|
|
|
// Check for reverse_proxy directive
|
|
if (!caddyfile.includes('reverse_proxy')) {
|
|
issues.push('No reverse_proxy directive found — site will not proxy traffic');
|
|
}
|
|
|
|
// Check for common mistakes
|
|
if (caddyfile.includes('tls ')) {
|
|
const tlsLine = caddyfile.split('\n').find(l => l.trim().startsWith('tls '));
|
|
if (tlsLine && tlsLine.includes('auto')) {
|
|
issues.push('tls auto is redundant — Caddy does this by default');
|
|
}
|
|
}
|
|
|
|
ok(res, {
|
|
valid: issues.length === 0,
|
|
issues,
|
|
warnings: [],
|
|
});
|
|
}));
|
|
|
|
// GET /api/v1/caddycode/templates — preset configs for common patterns
|
|
router.get('/caddycode/templates', wrap(async (req, res) => {
|
|
const templates = {
|
|
'simple-proxy': {
|
|
label: 'Simple Reverse Proxy',
|
|
config: {
|
|
domain: 'app.example.com',
|
|
upstream: 'localhost:8080',
|
|
tls: 'auto',
|
|
websocket: false,
|
|
auth: false,
|
|
},
|
|
},
|
|
'websocket-app': {
|
|
label: 'WebSocket Application',
|
|
config: {
|
|
domain: 'app.example.com',
|
|
upstream: 'localhost:3000',
|
|
websocket: true,
|
|
compress: true,
|
|
},
|
|
},
|
|
'auth-gated': {
|
|
label: 'Auth-Gated Service (DashCaddy SSO)',
|
|
config: {
|
|
domain: 'app.example.com',
|
|
upstream: 'localhost:8096',
|
|
auth: true,
|
|
authService: 'app',
|
|
},
|
|
},
|
|
'cors-api': {
|
|
label: 'API with CORS',
|
|
config: {
|
|
domain: 'api.example.com',
|
|
upstream: 'localhost:3001',
|
|
cors: true,
|
|
compress: true,
|
|
},
|
|
},
|
|
'subdirectory': {
|
|
label: 'Subdirectory Proxy',
|
|
config: {
|
|
domain: 'example.com',
|
|
upstream: 'localhost:8080',
|
|
stripPrefix: '/app',
|
|
},
|
|
},
|
|
};
|
|
|
|
ok(res, { templates });
|
|
}));
|
|
|
|
return router;
|
|
};
|
|
|
|
// DC-070: export helpers for unit-testing the sanitization surface
|
|
// independently of the route handler.
|
|
module.exports.__test = {
|
|
validateGenerationConfig,
|
|
escapeCaddyQuotedString,
|
|
generateSiteBlock,
|
|
};
|