| 1 | # Draft security report — not filed |
| 2 | |
| 3 | **For Paul to send, through whichever channel he judges right.** I have |
| 4 | not filed this anywhere. `github.com/carlosframework/rastrillo` is a |
| 5 | public repo and this is an unauthenticated server-side request forgery |
| 6 | affecting every app built on it, so where and how it gets disclosed is a |
| 7 | decision for the person who owns the org, not for an agent at 3am. |
| 8 | |
| 9 | Found 2026-09-05 while building memoria's sign-in dial guard (gate 33). |
| 10 | memoria's §3.2 has recorded the shallow version of this since its code |
| 11 | review — *"an anonymous visitor can make the instance dial any domain they |
| 12 | type"* — and Fable's review of QUESTIONS #6 said the fetcher's hardening |
| 13 | should be checked before assuming it was only an existence leak. It was |
| 14 | checked. It is not only an existence leak. |
| 15 | |
| 16 | --- |
| 17 | |
| 18 | ## The class |
| 19 | |
| 20 | **Unauthenticated SSRF with an attacker-chosen destination**, reachable |
| 21 | from the sign-in form of any rastrillo app, before any admission check. |
| 22 | |
| 23 | ## Where |
| 24 | |
| 25 | `github.com/keymaildev/signin`, `classify.go` — the `Classifier`, called |
| 26 | from `rastrillo/auth`'s `Begin` handler (`auth/handlers.go`, the |
| 27 | `a.flow.Begin(...)` call) which runs **before** `auth.Config.Authorize`. |
| 28 | |
| 29 | ## Mechanism |
| 30 | |
| 31 | 1. `POST /auth/begin` with any address. No session, no admission check. |
| 32 | 2. `Classifier.delegate` resolves TXT for `_keymail.<domain>`. |
| 33 | 3. A record of the form `v=… host=…` returns **`host` verbatim**. There is |
| 34 | no allowlist, no scheme check, no port restriction and no |
| 35 | private-address-range check on that value. |
| 36 | 4. `getJSON` then issues a GET to a URL built from it, using |
| 37 | `&http.Client{Timeout: 5s}` — **default redirect policy**, so up to ten |
| 38 | redirects are followed to wherever they point. |
| 39 | 5. On NXDOMAIN, `delegate` returns the bare domain, so the fetch happens |
| 40 | for essentially any syntactically valid domain, not only delegated ones. |
| 41 | |
| 42 | Anyone who controls DNS for any domain therefore chooses the destination. |
| 43 | The instance makes the request from its own network position. |
| 44 | |
| 45 | ## Why it matters more than the existence leak |
| 46 | |
| 47 | The recorded consequence was: instance existence, egress IP, timing |
| 48 | channel. The actual reach is whatever the instance's network can reach — |
| 49 | link-local metadata endpoints, private ranges, and internal services that |
| 50 | answer GET — with redirects followed and a 1 MiB body read. No content |
| 51 | returns to the attacker directly, which bounds it, but response *timing* |
| 52 | and *status* are observable through the sign-in flow's own behaviour. |
| 53 | |
| 54 | ## Suggested fix, and the pattern already exists in this codebase |
| 55 | |
| 56 | `amadan.net/oficina/suite`'s `transport.go` solves the same problem for |
| 57 | sibling calls and is the shape to copy: |
| 58 | |
| 59 | - the host is checked **before the transport dials**, so a refused host |
| 60 | costs no connection; |
| 61 | - redirects are refused outright rather than bounded — *"an allowed host |
| 62 | has to be reached to tell us to redirect"*; |
| 63 | - the check is exported separately from the client, because more than the |
| 64 | HTTP path needs it. |
| 65 | |
| 66 | For signin specifically: refuse private, loopback, link-local and |
| 67 | unspecified ranges after resolution (not before — resolve, then check the |
| 68 | IP, or the check is bypassable by DNS); pin the scheme to https and the |
| 69 | port to 443; refuse redirects. And in `rastrillo/auth`, **run |
| 70 | `Config.Authorize` before `flow.Begin`**, which removes the anonymous |
| 71 | reachability independently of the fetcher's own hardening. |
| 72 | |
| 73 | ## What memoria has done, and what it does not fix |
| 74 | |
| 75 | memoria's `beginGuard` (this branch, `0bd3023`) runs the admission |
| 76 | predicate before the classifier, so an unadmitted address triggers no DNS |
| 77 | query and no fetch. Gate 33 holds it, mutation-tested five ways. |
| 78 | |
| 79 | **It does not fix this.** It narrows memoria's exposure to *owners*, and |
| 80 | it does nothing for any other rastrillo app. An owner can still reach the |
| 81 | unhardened fetcher. The fix belongs upstream. |
| 82 | |
| 83 | ## Disclosure note |
| 84 | |
| 85 | Written to describe the defect and its fix. It deliberately contains no |
| 86 | ready-to-run reproduction. Whether it goes to a public issue, a private |
| 87 | advisory, or straight to a patch is Paul's call — a public issue on a |
| 88 | public repo publishes the reachability to everyone running a rastrillo app |
| 89 | before any of them can deploy a fix, which argues for the patch first. |
| 90 | |