oficina / memoria Public

๐Ÿค– security: the sign-in dial is an SSRF, not an existence leak

Read the fetcher instead of assuming it, which Fable's review of
QUESTIONS #6 asked for and nobody had done.

keymaildev/signin's classifier takes host= straight from the submitted
domain's _keymail TXT record โ€” no allowlist, no scheme or port
restriction, no private-range check โ€” and fetches it with a client that
follows redirects by default. On NXDOMAIN it fetches the bare domain
anyway, so the request happens for nearly any valid domain rather than
only delegated ones. rastrillo/auth calls this before Config.Authorize.

So anyone who controls DNS for any domain chooses where an unauthenticated
sign-in submission makes the instance send a GET, from its own network
position, with redirects followed. ยง3.2's "existence, egress IP, timing
channel" understates the reach by a lot.

NOT FILED, deliberately. rastrillo is a public repo and this affects every
app built on it; a public issue publishes the reachability to everyone
running one before any of them can deploy a fix. That disclosure decision
belongs to whoever owns the org. SECURITY-DRAFT-rastrillo-signin.md is the
write-up, describing the defect and the fix with no ready-to-run
reproduction, for Paul to send through whichever channel he judges right.

memoria's own guard (0bd3023) narrows this to owners here and fixes
nothing for anyone else. The fix belongs upstream, and the pattern already
exists in amadan.net/oficina/suite's transport.go: check the host before
the transport dials so a refused host costs no connection, and refuse
redirects outright rather than bounding them, because an allowed host has
to be reached to tell you to redirect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H3VKtXnq6fSmM2F11Bjcsy
Paul Campbell pushed by paul@keymail.dev 9cdc4d052062c4fe6c01aebc69b93d3d25173c06 parent ec9ef66
2 files changed, +109 −6
  • SECURITY-DRAFT-rastrillo-signin.md +89 −0
  • docs/superpowers/specs/2026-09-03-memoria-design.md +20 −6
diff --git a/SECURITY-DRAFT-rastrillo-signin.md b/SECURITY-DRAFT-rastrillo-signin.md
new file mode 100644
index 0000000..c4748b8
--- /dev/null
+++ b/SECURITY-DRAFT-rastrillo-signin.md
@@ -0,0 +1,89 @@
+# 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 27).
+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 27 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.
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index ea2559f..2ae876d 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -2271,12 +2271,26 @@ commit. Fixed 2026-09-05; ยง1.7's lesson arriving inside the fix for ยง1.6.*
- **The sign-in dial: memoria's half is closed, the upstream half is
not.** `POST /auth/begin` now runs the admission predicate before the
classifier resolves anything (gate 27), so an unadmitted address causes
- no DNS query and no fetch. **What remains is not memoria's:** the
- classifier in `rastrillo/auth` and `keymaildev/signin` still resolves
- and fetches before the app's `Authorize` callback for every app that
- uses it, and the fetcher's own hardening โ€” port pinning, redirect
- refusal, private-range refusal โ€” is unread. Every rastrillo app has this
- until it is fixed there. One residual here too, stated in the guard's
+ no DNS query and no fetch.
+ **What remains is not memoria's, and it is worse than this spec has been
+ recording.** The fetcher's hardening was read on 2026-09-05 and there is
+ none: `keymaildev/signin`'s classifier takes a `host=` straight from the
+ submitted domain's `_keymail` TXT record with no allowlist, no scheme or
+ port restriction and no private-range check, then fetches it with a
+ client that follows redirects by default โ€” and on NXDOMAIN it fetches
+ the bare domain anyway, so the request happens for nearly any valid
+ domain rather than only delegated ones. **That is an unauthenticated
+ SSRF with an attacker-chosen destination, in every rastrillo app**, and
+ ยง3.2's "leaks existence, egress IP and a timing channel" understates it:
+ the reach is whatever the instance's network can reach.
+ memoria's guard narrows this to *owners* here and does nothing for any
+ other app. `SECURITY-DRAFT-rastrillo-signin.md` in this repo is the
+ write-up, **unfiled on purpose** โ€” rastrillo is a public repo and a
+ public issue would publish the reachability to everyone running one
+ before any of them could deploy a fix. Paul's call. The fix pattern
+ already exists in `amadan.net/oficina/suite`'s `transport.go`: check the
+ host before the transport dials, and refuse redirects rather than bound
+ them. One residual here too, stated in the guard's
own comment: an *owner* whose domain delegates to keymail is redirected
to keymail's host rather than to `?sent=1`, so the two paths still
differ for keymail-delegated domains. Narrower than the status quo it