Refuse to boot with a logging mailer, and go live on edge
The first deploy found a gap no test had. rastrillo's mail.FromEnv falls back to mail.Logged when SMTP is unset, with nothing but a WARN — so fichas came up on a public origin wired to write every magic link into platform log storage we cannot grep and cannot retract. A live credential, in a log, and nothing failed. This app had ported memoria's FICHAS_ALLOW_LOGGED_MAIL flag but not the refusal the flag exists to guard, and the README claimed the opt-in was required while nothing checked. LoadConfig now refuses to start without CARLOS_SMTP_HOST and CARLOS_SMTP_FROM, and the opt-in is accepted only on a loopback origin. Three tests: missing either variable refuses, the hatch works on loopback, and the hatch cannot put a logging mailer on a real host. Platform email is enabled for the app, sending as noreply@fichas.bds.oncarlos.com. FICHAS_ORIGIN is the platform hostname rather than fichas.oficina.works: it is the CSRF yardstick and the base of every emailed link, so it must be the host the browser is actually on, and the custom name does not resolve yet. Live on edge at https://fichas.bds.oncarlos.com — /api/version reports the build, / redirects an unauthenticated visitor to /signin, and the card routes refuse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 files changed,
+96
−4
README.md+25 −4internal/fichas/config.go+22 −0internal/fichas/config_test.go+45 −0internal/fichastest/harness_test.go+4 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/fichas/cmd/fichas [no test files] ok amadan.net/oficina/fichas/internal/fichas 0.159s ? amadan.net/oficina/fichas/internal/fichas/icons [no test files] ok amadan.net/oficina/fichas/internal/fichas/seal (cached) ok amadan.net/oficina/fichas/internal/fichastest 0.113s === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/README.md b/README.md| index 29a0e47..2ac497f 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -133,7 +133,28 @@ every deployment. `FICHAS_OWNERS` has no default: an empty admission gate |
| on a public hostname admits anyone who can receive mail, so the app refuses |
| to start without one. |
| -Mail goes through the platform's sender (`CARLOS_*`). With none configured |
| -rastrillo substitutes `mail.Logged`, which writes the magic link — a live |
| -credential — into the process log; `FICHAS_ALLOW_LOGGED_MAIL=1` opts into |
| -that and is refused on any origin that is not loopback. |
| +Mail goes through the platform's sender: |
| + |
| +```sh |
| +carlos email enable --app fichas --account bds --from noreply@fichas.bds.oncarlos.com |
| +``` |
| + |
| +**The app refuses to boot without it.** `mail.FromEnv` falls back to |
| +`mail.Logged` on missing SMTP with nothing but a warning, and `mail.Logged` |
| +writes the magic link — a live credential — into platform log storage we |
| +cannot grep and cannot retract. The first deploy of this app ran in exactly |
| +that state and nothing failed; a WARN scrolled past. `LoadConfig` now |
| +refuses, and `FICHAS_ALLOW_LOGGED_MAIL=1` accepts it only on a loopback |
| +origin. |
| + |
| +## Live |
| + |
| +**https://fichas.bds.oncarlos.com** — account `bds`, channel `edge`. |
| +Sign in with an address in `FICHAS_OWNERS`. |
| + |
| +`FICHAS_ORIGIN` is that platform hostname, not `fichas.oficina.works`. It |
| +is the CSRF yardstick and the base of every emailed sign-in link, so it has |
| +to be the host the browser is actually on — pointing it at a name that does |
| +not resolve yet would send every link into the void. memoria made the same |
| +move in the other direction once its certificate issued, and its README |
| +records why being precise about this matters. |
diff --git a/internal/fichas/config.go b/internal/fichas/config.go| index 962e6c0..8916535 100644 |
| --- a/internal/fichas/config.go |
| +++ b/internal/fichas/config.go |
| @@ -29,6 +29,10 @@ type Config struct { |
| // admitted set; in the SUITE shape it answers the narrower question |
| // of who operates the instance (ruling 5, memoria §2). |
| Owners []string |
| + // MailConfigured reports whether real SMTP is wired. False means |
| + // rastrillo substitutes mail.Logged, which writes sign-in links into |
| + // the process log. |
| + MailConfigured bool |
| // Shape is "lone" or "suite" — ruling 5's one switch. Suite is the |
| // intended default; see ErrSuiteNotBuilt. |
| Shape string |
| @@ -97,6 +101,24 @@ func LoadConfig(getenv func(string) string) (Config, error) { |
| "and is local-development only, but FICHAS_ORIGIN is %s", c.Origin) |
| } |
| + // **Refusing the flag was only half the guard, and the first deploy |
| + // proved it.** rastrillo's mail.FromEnv falls back to mail.Logged |
| + // with nothing but a Warn when SMTP is unset, so fichas booted on a |
| + // public origin wired to write every magic link — a live credential, |
| + // usable by anyone who can read the log — into platform log storage |
| + // we cannot grep and cannot retract. Nothing failed; a WARN scrolled |
| + // past. memoria refuses to start in this state and this app claimed |
| + // to without doing it. |
| + // |
| + // The prefix is the suite's, matching what main hands mail.FromEnv. |
| + c.MailConfigured = getenv("CARLOS_SMTP_HOST") != "" && getenv("CARLOS_SMTP_FROM") != "" |
| + if !c.MailConfigured && !c.AllowLoggedMail { |
| + return Config{}, fmt.Errorf( |
| + "%w: CARLOS_SMTP_HOST and CARLOS_SMTP_FROM — without them every sign-in link is written "+ |
| + "to the log instead of sent, which is a live credential in storage we cannot retract; "+ |
| + "set FICHAS_ALLOW_LOGGED_MAIL=1 to accept that on a loopback origin", ErrMissing) |
| + } |
| + |
| c.Shape = strings.ToLower(strings.TrimSpace(getenv("FICHAS_SHAPE"))) |
| if c.Shape == "" { |
| c.Shape = ShapeLone |
diff --git a/internal/fichas/config_test.go b/internal/fichas/config_test.go| index 0396a14..266ce1c 100644 |
| --- a/internal/fichas/config_test.go |
| +++ b/internal/fichas/config_test.go |
| @@ -14,6 +14,8 @@ func env(m map[string]string) func(string) string { |
| "FICHAS_DEK": goodDEK(), |
| "FICHAS_INSTANCE_KEY": "k", |
| "FICHAS_OWNERS": "ana@example.com", |
| + "CARLOS_SMTP_HOST": "smtp.example.com", |
| + "CARLOS_SMTP_FROM": "fichas@example.com", |
| } |
| for k, v := range m { |
| if v == "" { |
| @@ -153,3 +155,46 @@ func TestAnUnknownShapeIsRefused(t *testing.T) { |
| t.Fatal("an unknown shape was accepted") |
| } |
| } |
| + |
| +// The guard the first deploy needed and did not have. |
| +// |
| +// mail.FromEnv falls back to mail.Logged with a Warn when SMTP is unset, |
| +// so the app booted on a public origin wired to write every magic link — |
| +// a live credential — into platform log storage. Nothing failed. A WARN |
| +// scrolled past. |
| +func TestBootIsRefusedWithNoMailer(t *testing.T) { |
| + for _, missing := range []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_FROM"} { |
| + _, err := LoadConfig(env(map[string]string{missing: unset})) |
| + if !errors.Is(err, ErrMissing) { |
| + t.Errorf("booting with no %s returned %v, want ErrMissing", missing, err) |
| + } |
| + } |
| +} |
| + |
| +// The escape hatch works, and only on loopback. |
| +func TestLoggedMailIsAllowedOnLoopbackWhenAskedFor(t *testing.T) { |
| + c, err := LoadConfig(env(map[string]string{ |
| + "CARLOS_SMTP_HOST": unset, |
| + "CARLOS_SMTP_FROM": unset, |
| + "FICHAS_ORIGIN": "http://127.0.0.1:8824", |
| + "FICHAS_ALLOW_LOGGED_MAIL": "1", |
| + })) |
| + if err != nil { |
| + t.Fatalf("LoadConfig: %v", err) |
| + } |
| + if c.MailConfigured { |
| + t.Fatal("MailConfigured is true with no SMTP") |
| + } |
| +} |
| + |
| +// And the hatch cannot be used to get a logging mailer onto a real host. |
| +func TestLoggedMailCannotBeAskedForOffLoopback(t *testing.T) { |
| + if _, err := LoadConfig(env(map[string]string{ |
| + "CARLOS_SMTP_HOST": unset, |
| + "CARLOS_SMTP_FROM": unset, |
| + "FICHAS_ORIGIN": "https://fichas.bds.oncarlos.com", |
| + "FICHAS_ALLOW_LOGGED_MAIL": "1", |
| + })); err == nil { |
| + t.Fatal("a logging mailer was accepted on a public origin") |
| + } |
| +} |
diff --git a/internal/fichastest/harness_test.go b/internal/fichastest/harness_test.go| index 23161e2..60e2ecd 100644 |
| --- a/internal/fichastest/harness_test.go |
| +++ b/internal/fichastest/harness_test.go |
| @@ -56,6 +56,10 @@ func testConfig(t *testing.T) fichas.Config { |
| return "test-instance-key" |
| case "FICHAS_OWNERS": |
| return TestOwner |
| + case "FICHAS_ALLOW_LOGGED_MAIL": |
| + // No SMTP in a test, and testOrigin is loopback. Saying so |
| + // out loud is the point of the flag. |
| + return "1" |
| } |
| return "" |
| }) |