# Draft security report — not filed

**For Paul to send, through whichever channel he judges right.** I have
not filed this anywhere. `github.com/carlosframework/rastrillo` is a
public repo and this is an unauthenticated server-side request forgery
affecting every app built on it, so where and how it gets disclosed is a
decision for the person who owns the org, not for an agent at 3am.

Found 2026-09-05 while building memoria's sign-in dial guard (gate 33).
memoria's §3.2 has recorded the shallow version of this since its code
review — *"an anonymous visitor can make the instance dial any domain they
type"* — and Fable's review of QUESTIONS #6 said the fetcher's hardening
should be checked before assuming it was only an existence leak. It was
checked. It is not only an existence leak.

---

## The class

**Unauthenticated SSRF with an attacker-chosen destination**, reachable
from the sign-in form of any rastrillo app, before any admission check.

## Where

`github.com/keymaildev/signin`, `classify.go` — the `Classifier`, called
from `rastrillo/auth`'s `Begin` handler (`auth/handlers.go`, the
`a.flow.Begin(...)` call) which runs **before** `auth.Config.Authorize`.

## Mechanism

1. `POST /auth/begin` with any address. No session, no admission check.
2. `Classifier.delegate` resolves TXT for `_keymail.<domain>`.
3. A record of the form `v=… host=…` returns **`host` verbatim**. There is
   no allowlist, no scheme check, no port restriction and no
   private-address-range check on that value.
4. `getJSON` then issues a GET to a URL built from it, using
   `&http.Client{Timeout: 5s}` — **default redirect policy**, so up to ten
   redirects are followed to wherever they point.
5. On NXDOMAIN, `delegate` returns the bare domain, so the fetch happens
   for essentially any syntactically valid domain, not only delegated ones.

Anyone who controls DNS for any domain therefore chooses the destination.
The instance makes the request from its own network position.

## Why it matters more than the existence leak

The recorded consequence was: instance existence, egress IP, timing
channel. The actual reach is whatever the instance's network can reach —
link-local metadata endpoints, private ranges, and internal services that
answer GET — with redirects followed and a 1 MiB body read. No content
returns to the attacker directly, which bounds it, but response *timing*
and *status* are observable through the sign-in flow's own behaviour.

## Suggested fix, and the pattern already exists in this codebase

`amadan.net/oficina/suite`'s `transport.go` solves the same problem for
sibling calls and is the shape to copy:

- the host is checked **before the transport dials**, so a refused host
  costs no connection;
- redirects are refused outright rather than bounded — *"an allowed host
  has to be reached to tell us to redirect"*;
- the check is exported separately from the client, because more than the
  HTTP path needs it.

For signin specifically: refuse private, loopback, link-local and
unspecified ranges after resolution (not before — resolve, then check the
IP, or the check is bypassable by DNS); pin the scheme to https and the
port to 443; refuse redirects. And in `rastrillo/auth`, **run
`Config.Authorize` before `flow.Begin`**, which removes the anonymous
reachability independently of the fetcher's own hardening.

## What memoria has done, and what it does not fix

memoria's `beginGuard` (this branch, `0bd3023`) runs the admission
predicate before the classifier, so an unadmitted address triggers no DNS
query and no fetch. Gate 33 holds it, mutation-tested five ways.

**It does not fix this.** It narrows memoria's exposure to *owners*, and
it does nothing for any other rastrillo app. An owner can still reach the
unhardened fetcher. The fix belongs upstream.

## Disclosure note

Written to describe the defect and its fix. It deliberately contains no
ready-to-run reproduction. Whether it goes to a public issue, a private
advisory, or straight to a patch is Paul's call — a public issue on a
public repo publishes the reachability to everyone running a rastrillo app
before any of them can deploy a fix, which argues for the patch first.
