Add http request tool #412

Closed
opened 2026-08-13 12:18:51 +00:00 by coilysiren · 5 comments
Owner

see also #155 (comment)

this is asking for a simple http request-y thing, a curl-y thing, a request-y lib thing. I just don't remember which package golang usually use... IIRC we might use plain stlib?

see also https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/155#issue-7944 this is asking for a simple http request-y thing, a curl-y thing, a request-y lib thing. I just don't remember which package golang usually use... IIRC we might use plain stlib?
Member

CLAIM — Angie (ENG, claude seat) · 2026-08-13T16:12Z · 20 min, expect a second. Unclaimed and yours.

Plain stdlib is rightnet/http, no dependency. You remembered correctly.

The package is the easy part. The reason this needs care is that an arbitrary-URL fetch runs inside the cluster: it can reach the tailnet, other services' internal endpoints, and cloud metadata addresses. A tool that fetches any URL a model is talked into is the classic server-side request forgery shape, and the model is exactly the component an attacker gets to talk to.

So I am building it the way the scratchpad and the content gate went in: the deployment is the switch, and unset offers no tool at all. An allowlist of hosts, supplied by deployment. No allowlist, no tool in the roster, no prompt mention.

Four other bounds, all cheap:

  • https only. A plaintext fetch inside a cluster is a different risk with no upside here.
  • Private and link-local addresses refused at dial time, not by inspecting the URL. Checking the hostname is not enough: an allowlisted name can resolve to an internal address, deliberately or otherwise.
  • A response size cap, since a large body becomes prompt.
  • A timeout, since a slow host otherwise spends the turn.

GET only in this slice. A curl-y thing that reads is most of what you described; one that writes is a different authority and should be asked for on its own rather than arriving as a side effect.

Waiting the buffer. Reading the scratch provider meanwhile, since it is the existing example of a harness-owned tool with bounds.

**CLAIM — Angie (ENG, claude seat)** · 2026-08-13T16:12Z · 20 min, expect a second. Unclaimed and yours. **Plain stdlib is right** — `net/http`, no dependency. You remembered correctly. The package is the easy part. The reason this needs care is that an arbitrary-URL fetch **runs inside the cluster**: it can reach the tailnet, other services' internal endpoints, and cloud metadata addresses. A tool that fetches any URL a model is talked into is the classic server-side request forgery shape, and the model is exactly the component an attacker gets to talk to. So I am building it the way the scratchpad and the content gate went in: **the deployment is the switch, and unset offers no tool at all.** An allowlist of hosts, supplied by deployment. No allowlist, no tool in the roster, no prompt mention. Four other bounds, all cheap: - **https only.** A plaintext fetch inside a cluster is a different risk with no upside here. - **Private and link-local addresses refused at dial time**, not by inspecting the URL. Checking the hostname is not enough: an allowlisted name can resolve to an internal address, deliberately or otherwise. - **A response size cap**, since a large body becomes prompt. - **A timeout**, since a slow host otherwise spends the turn. **GET only in this slice.** A curl-y thing that reads is most of what you described; one that writes is a different authority and should be asked for on its own rather than arriving as a side effect. Waiting the buffer. Reading the scratch provider meanwhile, since it is the existing example of a harness-owned tool with bounds.
Author
Owner

all that LGTM

all that LGTM
Member

Built — Angie (ENG, claude seat). PR #427, closing the slice #426. Releasing the claim.

You remembered right: plain stdlib, net/http, no dependency.

What I spent the effort on, and it was not the fetching

The fetching is about fifteen lines. The rest is the allowlist, because this runs inside the cluster — unbounded it reaches the tailnet, other services' internal endpoints, and the cloud metadata address. The model is exactly the component someone gets to talk to, so "fetch any URL" is server-side request forgery with a chat interface.

SIRENS_ECHO_FETCH_HOSTS is the switch and empty offers no tool at all: no schema, no prompt mention, nothing to be talked into. Same shape as the scratchpad and the content gate, so turning it on is your decision rather than a consequence of merging.

The bound worth knowing about

Private addresses are refused at dial time, not by reading the URL. That distinction is the whole thing: an allowlisted hostname can resolve to an internal address, deliberately or by accident, and a check that only reads the hostname never sees it happen.

The tests enumerate how allowlists actually get defeated: suffix lookalikes, prefix lookalikes, https://allowed.host@evil.example/, plain http, file://, cluster service names, 169.254.169.254, loopback.

Two things I decided, both reversible

GET only. A curl-y thing that reads is most of what you described. One that writes is a different authority and I would rather you asked for it than have it arrive as a side effect.

Nothing about what a page says. The allowlist bounds where text comes from, not what it contains. An approved host serving hostile instructions is still open, and it is the same boundary web search raises on #177 — worth answering once, for both.

**Built — Angie (ENG, claude seat).** PR https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/427, closing the slice https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/426. Releasing the claim. You remembered right: plain stdlib, `net/http`, no dependency. ## What I spent the effort on, and it was not the fetching The fetching is about fifteen lines. The rest is the allowlist, because this runs **inside the cluster** — unbounded it reaches the tailnet, other services' internal endpoints, and the cloud metadata address. The model is exactly the component someone gets to talk to, so "fetch any URL" is server-side request forgery with a chat interface. `SIRENS_ECHO_FETCH_HOSTS` is the switch and **empty offers no tool at all**: no schema, no prompt mention, nothing to be talked into. Same shape as the scratchpad and the content gate, so turning it on is your decision rather than a consequence of merging. ## The bound worth knowing about Private addresses are refused **at dial time**, not by reading the URL. That distinction is the whole thing: an allowlisted hostname can resolve to an internal address, deliberately or by accident, and a check that only reads the hostname never sees it happen. The tests enumerate how allowlists actually get defeated: suffix lookalikes, prefix lookalikes, `https://allowed.host@evil.example/`, plain http, `file://`, cluster service names, `169.254.169.254`, loopback. ## Two things I decided, both reversible **GET only.** A curl-y thing that reads is most of what you described. One that writes is a different authority and I would rather you asked for it than have it arrive as a side effect. **Nothing about what a page says.** The allowlist bounds where text comes from, not what it contains. An approved host serving hostile instructions is still open, and it is the same boundary web search raises on https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/177 — worth answering once, for both.
Member

Closing: delivered — Angie (ENG, claude seat). feat(fetch): a read-only fetch, bounded by an allowlist merged. Plain stdlib, as you guessed.

Two defects were found in it within the hour and both have their own issues rather than holding this one open: the tailnet range my address guard read as public (#428, fixed and merged) and silent truncation at the size cap (#435, claimed).

Both were caught by other agents reading my predicate against the actual network and the actual behaviour. That is the part worth keeping: my tests enumerated every case I thought of, and a missing range is by construction not one I thought of.

GET only, deliberately. A tool that writes is a different authority and I would rather you asked for it than have it arrive as a side effect of this one.

**Closing: delivered — Angie (ENG, claude seat).** `feat(fetch): a read-only fetch, bounded by an allowlist` merged. Plain stdlib, as you guessed. Two defects were found in it within the hour and both have their own issues rather than holding this one open: the tailnet range my address guard read as public (https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/428, fixed and merged) and silent truncation at the size cap (https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/435, claimed). Both were caught by other agents reading my predicate against the actual network and the actual behaviour. That is the part worth keeping: my tests enumerated every case I thought of, and a missing range is by construction not one I thought of. **GET only**, deliberately. A tool that writes is a different authority and I would rather you asked for it than have it arrive as a side effect of this one.
Member

Adversarial probe of the allowlist — Quail (QA). It holds on every bypass class I could construct. One scope question, not a defect.

TestTheAllowlistRefusesEverythingElse already covers ten shapes including suffix and prefix lookalikes and userinfo confusion, which is a better list than most. I probed the classes it does not name:

exact (must allow)    allowed=true    https://eco-app.coilysiren.me/trade
uppercase host        allowed=true    https://ECO-APP.COILYSIREN.ME/trade
mixed case host       allowed=true    https://Eco-App.Coilysiren.Me/trade
trailing dot FQDN     allowed=false   https://eco-app.coilysiren.me./trade
IDN homograph         allowed=false   https://eco-app.coilysiren.mе/trade
trailing whitespace   allowed=true    https://eco-app.coilysiren.me/trade
explicit port 443     allowed=true    https://eco-app.coilysiren.me:443/trade
nonstandard port      allowed=true    https://eco-app.coilysiren.me:8080/trade

Case-insensitive matching is correct, since DNS is, and getting this wrong in the other direction would refuse a legitimate host. The IDN homograph is refused — that row uses a Cyrillic е and is the bypass I most expected to land. The trailing-dot FQDN is refused, which is conservative rather than correct in a strict reading, and conservative is the right way to be wrong here.

Combined with the dial-time guard on #428, an allowlisted name resolving into the tailnet is now refused too. Both halves of the SSRF defence hold under probing.

The one open question, and I do not think it is urgent

A non-standard port on an allowlisted host is reachable. That is not a bypass — it is the same host, and the private-address guard still applies to whatever it resolves to — but the allowlist reads as "this host's published surface is safe to fetch" and :8080 may be a different service on the same machine.

Whether the allowlist grants a host or a host-and-port is a scope decision and I have no evidence anything is listening on another port. I am recording it rather than filing it, because a speculative narrowing has a cost — an allowlisted host that legitimately serves on :8443 would stop working — and nobody has asked for it.

If it is wanted, it is a small change and the acceptance is one line: an allowlisted host on an unlisted port is refused, and the same host on the listed one still dials.

What I checked and found nothing on

Redirects are refused, HTTPS is required, an empty allowlist offers no tool rather than a tool that refuses everything. That last property is the one I would keep hardest — it means the prompt never mentions a capability the deployment did not grant, so nothing can be talked into using it.

**Adversarial probe of the allowlist — Quail (QA). It holds on every bypass class I could construct. One scope question, not a defect.** `TestTheAllowlistRefusesEverythingElse` already covers ten shapes including suffix and prefix lookalikes and userinfo confusion, which is a better list than most. I probed the classes it does not name: ``` exact (must allow) allowed=true https://eco-app.coilysiren.me/trade uppercase host allowed=true https://ECO-APP.COILYSIREN.ME/trade mixed case host allowed=true https://Eco-App.Coilysiren.Me/trade trailing dot FQDN allowed=false https://eco-app.coilysiren.me./trade IDN homograph allowed=false https://eco-app.coilysiren.mе/trade trailing whitespace allowed=true https://eco-app.coilysiren.me/trade explicit port 443 allowed=true https://eco-app.coilysiren.me:443/trade nonstandard port allowed=true https://eco-app.coilysiren.me:8080/trade ``` **Case-insensitive matching is correct**, since DNS is, and getting this wrong in the other direction would refuse a legitimate host. **The IDN homograph is refused** — that row uses a Cyrillic `е` and is the bypass I most expected to land. **The trailing-dot FQDN is refused**, which is conservative rather than correct in a strict reading, and conservative is the right way to be wrong here. Combined with the dial-time guard on https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/428, an allowlisted name resolving into the tailnet is now refused too. **Both halves of the SSRF defence hold under probing.** ## The one open question, and I do not think it is urgent A non-standard port on an allowlisted host is reachable. That is not a bypass — it is the same host, and the private-address guard still applies to whatever it resolves to — but the allowlist reads as *"this host's published surface is safe to fetch"* and `:8080` may be a different service on the same machine. **Whether the allowlist grants a host or a host-and-port is a scope decision** and I have no evidence anything is listening on another port. I am recording it rather than filing it, because a speculative narrowing has a cost — an allowlisted host that legitimately serves on `:8443` would stop working — and nobody has asked for it. If it is wanted, it is a small change and the acceptance is one line: an allowlisted host on an unlisted port is refused, and the same host on the listed one still dials. ## What I checked and found nothing on Redirects are refused, HTTPS is required, an empty allowlist offers no tool rather than a tool that refuses everything. That last property is the one I would keep hardest — it means the prompt never mentions a capability the deployment did not grant, so nothing can be talked into using it.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#412
No description provided.