commit 7e0a0d6d31495a7e4e8f5c7c2b9d301fbe2eb16e from: isaac date: Fri Jun 26 23:53:17 2026 UTC fetch: http_allow wildcard for allow-all-minus-block posture acl_host_match treats a bare "*" token as matching any host, so http_allow "*" plus an http_block list gives an allow-all-minus-blocklist policy for the http_request tool without new config keys, parser, or imsg changes. The SSRF guard is unchanged (private/loopback/link-local still refused), so even wide open this brokered path stays far safer than allow_subprocess_net -- removing the incentive to reach for unguarded subprocess networking. Documents "*" in fugu.conf.5 with the exfiltration caveat (default stays default-deny; "*" is an explicit operator choice). Adds three regress/fetch cases: wildcard allows an un-listed host, block wins over the wildcard, and an unrelated block does not over-match. Full regress 710 -> 713, 0 failures. Issue: .scratch/http-allow-wildcard/ commit - 6b55003318869d06a4f7b148d2bf0649d22053ac commit + 7e0a0d6d31495a7e4e8f5c7c2b9d301fbe2eb16e blob - /dev/null blob + 21cb9b1b9bc4364b65df52a58528c6bd70c6bb94 (mode 644) --- /dev/null +++ .scratch/http-allow-wildcard/README.md @@ -0,0 +1,96 @@ +# http_allow wildcard: an easy "allow all, block some" posture for http_request + +Status: done (2026-06-26) +Origin: design discussion 2026-06-26 — incentive problem with `allow_subprocess_net`. + +## Outcome (2026-06-26) + +Landed. `acl_host_match` now treats a bare `*` token as matching any host, so +`http_allow "*"` plus an `http_block` list gives allow-all-minus-blocklist +without touching config keys, the parser, or imsg. The SSRF guard is unchanged +and still refuses private/loopback/link-local. Three integration cases added to +`regress/fetch` (h15: `*` allows an un-listed host; h16: block wins over the +wildcard; h17: an unrelated block does not over-match). `make` and +`make FUGU_DEBUG=1` clean; full regress 710 → 713 checks across 22 suites, 0 +failures. Man page documents `*` with the exfiltration caveat (default stays +default-deny; `*` is an explicit operator choice). + +## Goal + +Give the `http_allow` host list a single catch-all token, `*`, so the operator +can run the brokered `http_request` tool in **allow-all-minus-blocklist** mode, +not only the current **block-all-plus-allowlist** mode. With this: + +``` +# today's only posture (stays the default) +http_allow "api.github.com .stripe.com" + +# new posture, now trivial +http_allow "*" +http_block ".internal.corp 169.254.169.254" +``` + +## Why this matters + +`http_request` is default-deny with no wildcard, so opening it broadly means +enumerating every host. Meanwhile `allow_subprocess_net yes` is a one-line +switch to **unguarded** full network access (raw TCP, no SSRF guard, no host +ACL). The safe, brokered tool is therefore *more painful* than the unsafe +escape hatch — so a user who "just wants to do stuff" flips on subprocess net +and throws away every protection `http_request` provides. + +Making `http_allow "*"` trivial removes that incentive. Even wide open, the +brokered path keeps what subprocess net discards: + +- the SSRF guard stays on (private/loopback/link-local/metadata still refused); +- HTTPS-only, through fugu's own HTTP layer (no raw sockets, no arbitrary + protocols); +- brokered, auditable, body-size-capped. + +So "allow all but block a few" via `http_request` is both the *easy* choice and +still dramatically safer than subprocess net — which is the whole point. + +## Scope / what changes + +Only the host matcher and its docs. No new config key (reuse `http_allow` / +`http_block`), no parser change, no imsg change. + +- `src/fetch/fetch_run.c` — `acl_host_match`: a token that is exactly `*` + matches any host. Placed before the `.`-suffix branch. Because the wildcard + lives in `acl_host_match`, the path-prefix machinery composes for free: + `*/v1` means "any host, path under /v1", and `http_block "*"` symmetrically + blocks everything (a logical, harmless consequence of the shared matcher). +- `man/fugu.conf.5` — document the `*` bullet under `http_allow`, noting it is + the explicit opt-in to allow-all, that the SSRF guard still applies, and that + the default remains default-deny. +- `regress/fetch/fetch_test.c` — integration cases mirroring the existing ACL + tests: with `http_allow "*"` a request to an un-listed host (the loopback + stub) is allowed and reaches it; with `http_allow "*"` + `http_block` of that + host, the block still wins. + +## Constraints + +- OpenBSD KNF. +- `*` stays opt-in; the default with no `http_allow` is unchanged (tool not + offered, default-deny). `*` is a deliberate operator choice — `http_request` + in allow-all mode is a real exfiltration channel (arbitrary POST to any + public host), and blocklists are leaky, so the man page must say so plainly. +- Subdomain matching is still the `.example.com` form; `*` is a whole-token + catch-all only (no `*.example.com` glob — keep the matcher minimal). +- A focused regress test lands with the change; `make` and + `make FUGU_DEBUG=1` stay clean; full `make regress` green. + +## Verification + +- `make` and `make FUGU_DEBUG=1` clean. +- `make -C regress/fetch regress` — new wildcard cases pass. +- `make -C regress regress` — all suites green, total check count rises. + +## Out of scope + +- Host-scoped *subprocess* networking (the brokered-proxy idea) — deliberately + shelved; `pledge`'s `inet` is all-or-nothing and the proxy is a much larger + feature. +- Any `*.foo` glob syntax or regex host patterns. +- Changing `web_fetch` (already allow-all + SSRF-guarded) or `web_search` + (fixed Kagi host). blob - eddfbc12ede46945df2f202c02619486e18a086d blob + 906619c9ded09d843bbd6d7290bb3dd12aa63d7a --- man/fugu.conf.5 +++ man/fugu.conf.5 @@ -190,6 +190,11 @@ matches .Qq example.com and any subdomain of it; .It +.Qq * +on its own matches any host, turning the list into allow-all; combine it with +.Ic http_block +to allow everything except named hosts; +.It an optional .Pa / Ns Ar pathprefix further requires the request path to begin with it @@ -202,6 +207,19 @@ operator already lists, using whatever headers it supp SSRF guard .Pq private, loopback and link-local addresses refused also applies. +The default with no +.Ic http_allow +is to offer no +.Ic http_request +tool at all. +.Qq * +is therefore an explicit decision to let the model +.Sy POST +arbitrary bodies to any public host, which is a data-exfiltration channel a +leaky +.Ic http_block +list will not fully close; prefer an enumerated allowlist unless you have a +reason to open it wide. .It Ic http_block Ar string A whitespace-separated list of patterns, in the same form as .Ic http_allow , blob - 2e8fe2cad202c565c37a9709f79a256dffe9fce6 blob + 0b2c75196d2c709c04b2afb5008760e3f14a33b3 --- regress/fetch/fetch_test.c +++ regress/fetch/fetch_test.c @@ -729,6 +729,61 @@ main(int argc, char *argv[]) "http_request dot-suffix rejects evillocalhost vs .localhost " "(label-boundary anchor): '%s'", content); + /* + * Wildcard "*": allow-all-minus-blocklist. "localhost" is in no + * allow pattern but "*" matches any host, so the request reaches the + * stub; a matching http_block still wins over the wildcard. + */ + send_msg(&ip, M_CONFIG, + "{\"http_allow\":\"*\",\"http_block\":\"\"}"); + + /* "*" allows an otherwise un-listed host. */ + { + struct buf in; + + buf_init(&in); + buf_printf(&in, "{\"url\":\"https://localhost:%d/page\"}", port); + buf_terminate(&in); + send_req(&ip, "h15", "http_request", in.data); + buf_free(&in); + } + recv_result(&ip, content, sizeof(content), &is_err); + CHECK(!is_err && strstr(content, "HTTP 200") != NULL, + "http_request wildcard allows an un-listed host: '%s'", content); + + /* A block pattern still wins over the wildcard allow. */ + send_msg(&ip, M_CONFIG, + "{\"http_allow\":\"*\",\"http_block\":\"localhost\"}"); + { + struct buf in; + + buf_init(&in); + buf_printf(&in, "{\"url\":\"https://localhost:%d/page\"}", port); + buf_terminate(&in); + send_req(&ip, "h16", "http_request", in.data); + buf_free(&in); + } + recv_result(&ip, content, sizeof(content), &is_err); + CHECK(is_err && strstr(content, "not allowed") != NULL, + "http_request block wins over wildcard allow: '%s'", content); + + /* A block for an unrelated host does not over-match under "*". */ + send_msg(&ip, M_CONFIG, + "{\"http_allow\":\"*\",\"http_block\":\".example\"}"); + { + struct buf in; + + buf_init(&in); + buf_printf(&in, "{\"url\":\"https://localhost:%d/page\"}", port); + buf_terminate(&in); + send_req(&ip, "h17", "http_request", in.data); + buf_free(&in); + } + recv_result(&ip, content, sizeof(content), &is_err); + CHECK(!is_err && strstr(content, "HTTP 200") != NULL, + "http_request wildcard + unrelated block still allows: '%s'", + content); + send_msg(&ip, M_SHUTDOWN, NULL); if (waitpid(fp, &status, 0) == -1) err(1, "waitpid fetch"); blob - 0b9b7fabd62b9627986ed889b0a6d9d71e25344f blob + f132225499685a31b92296307b18cfdcf84fd8cd --- src/fetch/fetch_run.c +++ src/fetch/fetch_run.c @@ -1060,7 +1060,9 @@ acl_is_ws(int c) /* * Case-insensitive match of host against the host part of one pattern token. * A leading '.' makes it a domain suffix: ".example.com" matches both the bare - * "example.com" and any "*.example.com". + * "example.com" and any "*.example.com". A bare "*" matches any host, so + * http_allow "*" plus an http_block list yields an allow-all-minus-block + * posture (the SSRF guard still refuses private/loopback addresses). */ static int acl_host_match(const char *pat, size_t patlen, const char *host) @@ -1069,6 +1071,8 @@ acl_host_match(const char *pat, size_t patlen, const c if (patlen == 0) return (0); + if (patlen == 1 && pat[0] == '*') /* catch-all: any host */ + return (1); if (pat[0] == '.') { const char *bare = pat + 1; size_t barelen = patlen - 1;