Files
dashcaddy/dashcaddy-api/src/utils/http.js
T
Hermes 933606ce3f fix(caddy-admin): IPv6 loopback origin allowlist + bracket-strip helper (DC-069) [glm-grade=A]
Two coupled bugs that, together, cause the live 'admin.api received request
from ::1 → 403 client is not allowed to access from origin' noise on DNS2:

  (1) Caddyfile 'origins' allowlist (admin 0.0.0.0:2019 block on DNS2) had
      4 IPv4 entries (localhost/127.0.0.1/172.17.0.1/0.0.0.0) but no IPv6
      entry. Per glibc RFC 3484 + /etc/hosts '::1 localhost', Node's
      dns.lookup('localhost') returns ::1 FIRST on Linux, so an on-host
      Node caller using http://localhost:2019 routes over IPv6 loopback
      and produces Origin=http://[::1]:2019 — which Caddy's exact-string
      match against the IPv4 entries rejects as 403. Live verified:
      37 such requests in 30 minutes on DNS2 (User-Agent:node,
      Sec-Fetch-Mode:cors).

  (2) _httpFetch (src/utils/http.js) was broken for IPv6 literal URLs:
      on Node 22, new URL('http://[::1]:2019/x').hostname === '[::1]'
      (brackets preserved), but http.request({hostname}) needs the
      BRACKETLESS form for actual TCP connect. Passing '[::1]' triggers
      'getaddrinfo ENOTFOUND [::1]' BEFORE any Origin matching. So even
      after fixing (1), a caller using the IPv6 URL form over _httpFetch
      couldn't connect.

Fixes:

  (1) _httpFetch computes transportHostname by stripping leading [ and
      trailing ] when parsed.hostname is bracket-wrapped. transports via
      bracketless form. defaultOrigin keeps bracket form so Caddy's
      allowlist exact-matches. Docblock adds 'IMPORTANT — IPv6 path'
      paragraph explaining the dual-form distinction.

  (2) dashcaddy-installer/templates/Caddyfile.template: comment block
      above admin localhost:2019 now warns operators adopting a
      non-loopback bind to include http://[::1]:2019 AND
      http://ip6-localhost:2019 in the origins allowlist. Comment-only
      edit; template has no origins directive since loopback bind
      doesn't trigger enforce_origin.

Tests (NEW utils-http-caddy-admin-ipv6-origin.test.js, 4 cases):
  - template comment mentions IPv6 ([::1]/ip6-localhost/IPv6 substring)
  - stripComments helper preserves template literals with // inside
    (eslint no-control-regex forces non-regex split)
  - end-to-end: real http server on [::1]:20191, fetchT succeeds 200,
    Origin header is exactly 'http://[::1]:20191'
  - end-to-end bug repro: same setup with IPv4-only allowlist returns
    403 (proves the mock allowlist check actually runs)

DC-051's utils-http-caddy-admin-origin.test.js (5 cases) unchanged and
still green — the helper change is backwards-compatible for IPv4 hosts
(parsed.hostname.startsWith('[') is false for 127.0.0.1/localhost/
172.17.0.1).

Full suite: 2281/2281 (98 suites, +4 net new). ESLint clean on touched
files.

GLM-5.3 judge round 1 (35s, 3 tool calls): GRADE=A. 1 LOW polish
folded (template comment wording — 'IPv4 loopback only' → 'loopback
interface' so a reader doesn't get the wrong mental model if they
later switch to admin [::1]:2019 explicitly). No blocking issues.
2026-08-18 13:34:51 -07:00

222 lines
8.2 KiB
JavaScript

/**
* HTTP utilities - Fetch helpers and HTTP operations
*/
const http = require('http');
const https = require('https');
const { TIMEOUTS } = require('../utilities/constants');
// HTTPS agent that trusts internal CA certs (self-signed .sami TLD etc.)
// Lazy-initialized singleton to avoid creating a new agent per request.
let _internalHttpsAgent;
function getInternalHttpsAgent() {
if (!_internalHttpsAgent) {
_internalHttpsAgent = new https.Agent({ rejectUnauthorized: false });
}
return _internalHttpsAgent;
}
/**
* Fetch with automatic timeout
* Drop-in replacement for fetch() with AbortSignal timeout
*
* Handles two cases where native fetch() doesn't work:
* 1. Caddy admin API (:2019) - rejects undici fetch
* 2. HTTPS with self-signed certs (.sami TLD) - undici can't use Node's tls agent
*/
function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
// Caddy admin API rejects Node.js undici fetch - use raw http.request
if (url.includes(':2019')) {
return _httpFetch(url, opts, timeoutMs);
}
// HTTPS with self-signed certs: use raw https.request with rejectUnauthorized:false
// Node.js native fetch() (undici) ignores the `agent` option and always validates certs.
if (url.startsWith('https://')) {
return _httpsFetch(url, opts, timeoutMs);
}
if (!opts.signal) {
opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) };
}
// The `timeout` key in fetch() opts is silently ignored by undici. Callers
// should use the third arg of fetchT() (timeoutMs) instead. If a caller
// passes `timeout: N` here, it's almost certainly a bug — we used to silently
// strip it, which masked the issue. Now we surface it in logs and strip it.
if ('timeout' in opts) {
process.stderr.write(`[fetchT] opts.timeout=${opts.timeout} is ignored — pass timeoutMs as the 3rd arg of fetchT() instead. Called from: ${new Error().stack.split('\n').slice(2, 4).join(' <- ')}\n`);
const { timeout: _timeout, ...rest } = opts;
opts = rest;
}
return fetch(url, opts);
}
/**
* Raw https.request wrapper for self-signed cert support
* Node.js native fetch() (undici) cannot be configured with rejectUnauthorized:false,
* so we use the standard https module for internal HTTPS endpoints.
*/
function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.pathname + parsed.search,
method: (opts.method || 'GET').toUpperCase(),
headers: { ...opts.headers },
timeout: timeoutMs,
agent: getInternalHttpsAgent(),
};
if (opts.body && !options.headers['Content-Length']) {
options.headers['Content-Length'] = Buffer.byteLength(opts.body);
}
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const req = https.request(options, (res) => {
let data = '';
let size = 0;
res.on('data', chunk => {
size += chunk.length;
if (size > MAX_RESPONSE_SIZE) {
res.destroy();
reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`));
return;
}
data += chunk;
});
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
statusText: res.statusMessage,
json: () => Promise.resolve(JSON.parse(data)),
text: () => Promise.resolve(data),
headers: {
get: (k) => res.headers[k.toLowerCase()],
getSetCookie: () => {
const sc = res.headers['set-cookie'];
if (!sc) return [];
return Array.isArray(sc) ? sc : [sc];
}
},
});
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`));
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
/**
* Raw http.request wrapper for Caddy admin API
*
* Auto-injects `Origin: http://<host>:<port>` because Caddy's admin API on a
* non-loopback bind (e.g. `admin 0.0.0.0:2019` so the DashCaddy docker
* container can probe it from 172.17.0.1) enables `enforce_origin` and
* rejects every request whose Origin isn't in the admin's `origins` allowlist
* OR is empty. Node's undici fetch sets `Sec-Fetch-Mode: cors` which triggers
* the check; raw http.request sets no Origin at all, which fails the empty
* check. Setting Origin to the admin endpoint's own origin satisfies
* gorilla/csrf same-origin and is the documented override.
* (See: https://caddyserver.com/docs/caddyfile/options — `origins` directive.)
*
* Caller-provided `Origin` header (via opts.headers) wins so tests / future
* proxies can override; default matches the parsed admin URL.
*
* IMPORTANT — IPv6 path (DC-069): on Linux, `dns.lookup('localhost')` returns
* `::1` FIRST (per RFC 3484, because /etc/hosts has `::1 localhost`). When the
* caller passes `http://localhost:2019/...`, `parsed.hostname` is `::1` AND
* the auto-injected Origin is `http://[::1]:2019` — which means the Caddy
* `origins` allowlist MUST contain `http://[::1]:2019` (and ideally
* `http://ip6-localhost:2019` for the glibc alias), otherwise every on-host
* Node probe via `localhost` gets a 403 with empty-Origin-looking error.
* The corresponding `origins` entries live in `/etc/caddy/Caddyfile` on DNS2
* (committed via `caddy-apply`) and are documented in
* `dashcaddy-installer/templates/Caddyfile.template`.
*/
function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const defaultOrigin = `${parsed.protocol}//${parsed.hostname}:${parsed.port || 2019}`;
// Node 22's WHATWG URL parser preserves the brackets around IPv6
// literals in `parsed.hostname` (e.g. '[::1]'), but `http.request({hostname})`
// expects the BRACKETLESS form for actual connection — passing '[::1]'
// triggers `getaddrinfo ENOTFOUND [::1]` and the request fails before
// any Origin matching happens. Caddy's `enforce_origin` allowlist
// matches by exact Origin string (which DOES include the brackets),
// so we keep `defaultOrigin` bracket-form for the header but strip them
// for the transport-layer hostname. (DC-069 — IPv6 admin probe path.)
const transportHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
? parsed.hostname.slice(1, -1)
: parsed.hostname;
const options = {
hostname: transportHostname,
port: parsed.port || 2019,
path: parsed.pathname + parsed.search,
method: (opts.method || 'GET').toUpperCase(),
headers: {
Origin: defaultOrigin,
...opts.headers,
},
timeout: timeoutMs,
};
if (opts.body) {
options.headers['Content-Length'] = Buffer.byteLength(opts.body);
}
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const req = http.request(options, (res) => {
let data = '';
let size = 0;
res.on('data', chunk => {
size += chunk.length;
if (size > MAX_RESPONSE_SIZE) {
res.destroy();
reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`));
return;
}
data += chunk;
});
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
statusText: res.statusMessage,
json: () => Promise.resolve(JSON.parse(data)),
text: () => Promise.resolve(data),
headers: {
get: (k) => res.headers[k.toLowerCase()],
getSetCookie: () => {
const sc = res.headers['set-cookie'];
if (!sc) return [];
return Array.isArray(sc) ? sc : [sc];
}
},
});
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`));
});
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
module.exports = { fetchT };