commit - 6b55003318869d06a4f7b148d2bf0649d22053ac
commit + 7e0a0d6d31495a7e4e8f5c7c2b9d301fbe2eb16e
blob - /dev/null
blob + 21cb9b1b9bc4364b65df52a58528c6bd70c6bb94 (mode 644)
--- /dev/null
+++ .scratch/http-allow-wildcard/README.md
+# 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
.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
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
"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
/*
* 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)
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;