/** * 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, };