Commit Diff


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;