commit 99ec7e2e0b756301359cddd1d5478e18e84c3f8c equwal <truex@equwal.com> 2026-09-20 16:19:51 -0700 Dev sign-in links are an explicit opt-in, never inferred With no SMTP host, /api/auth/request returned the sign-in link in its response whenever billing was off - on the assumption that billing off means localhost. A public server can have billing off (this one does), and there it let anyone sign in as any address. It is now refused unless SUBPLZ_WEB_DEV_LOGIN_LINKS is set, and the link is no longer logged.
.env.example | 8 +++++--- .gitattributes | 1 + backend/api.py | 14 +++++--------- backend/mailer.py | 8 ++++---- backend/settings.py | 11 +++++++++-- tests/test_auth.py | 17 +++++++++++------ 6 files changed, 35 insertions(+), 24 deletions(-)
diff --git a/.env.example b/.env.example index b164734..48b6822 100644 --- a/.env.example +++ b/.env.example @@ -68,14 +68,16 @@ SUBPLZ_WEB_FREE_WINDOW_HOURS=24 # SUBPLZ_WEB_STRIPE_SECRET_KEY= # SUBPLZ_WEB_STRIPE_WEBHOOK_SECRET= -# Sign-in links go out over SMTP, so any provider works. With no host set the -# link is written to the server log instead (and sign-in is refused outright -# when billing is on, since there would be no safe way to deliver it). +# Sign-in links go out over SMTP, so any provider works. With no host set, +# sign-in is unavailable: there would be no safe way to deliver the link. # SUBPLZ_WEB_SMTP_HOST=smtp.example.com # SUBPLZ_WEB_SMTP_PORT=587 # SUBPLZ_WEB_SMTP_USER= # SUBPLZ_WEB_SMTP_PASSWORD= # SUBPLZ_WEB_SMTP_FROM=SubRead <login@subread.space> +# Development only: return the sign-in link in the API response instead of +# mailing it. This signs anyone in as any address - never set it in public. +# SUBPLZ_WEB_DEV_LOGIN_LINKS=true # Override the plan catalogue (see backend/pricing.py for the shape). # SUBPLZ_WEB_PLANS_JSON=[...] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/backend/api.py b/backend/api.py index 573e835..7220f1e 100644 --- a/backend/api.py +++ b/backend/api.py @@ -281,9 +281,7 @@ def _account_out(session: Session, account: Account) -> AccountOut: email=account.email, billing_enabled=settings.billing_enabled, payments_available=settings.payments_configured, - email_sign_in_available=( - settings.email_configured or not settings.billing_enabled - ), + email_sign_in_available=settings.sign_in_available, free_allowance=ent.free_allowance, free_window_hours=ent.window_hours, free_tier_summary=pricing.free_tier_summary(), @@ -329,9 +327,9 @@ def request_sign_in( except accounts.InvalidEmail as exc: raise HTTPException(400, str(exc)) from exc - # A public server that cannot send mail has no safe way to do this: the - # only fallback is showing the link, which would sign anyone in as anyone. - if settings.billing_enabled and not settings.email_configured: + # A server that cannot send mail has no safe way to do this: the only + # fallback is showing the link, which would sign anyone in as anyone. + if not settings.sign_in_available: raise HTTPException( 503, "Email sign-in is not set up on this server yet." ) @@ -347,9 +345,7 @@ def request_sign_in( raise HTTPException(502, str(exc)) from exc out = {"sent": sent, "email": email} - if not sent: - # Localhost only (guarded above): there is no mailbox to check, so - # hand the link straight back. + if not sent and settings.dev_login_links: out["dev_link"] = link return out diff --git a/backend/mailer.py b/backend/mailer.py index a753569..64705e0 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -16,13 +16,13 @@ class MailError(RuntimeError): def send_login_link(to: str, url: str) -> bool: - """Email a sign-in link. Returns False when it was only logged. + """Email a sign-in link. Returns False when there is no mail server. - With no SMTP host configured the link goes to the server log, which keeps - sign-in usable on localhost without an email account to send from. + That only happens in development (dev_login_links), where the API hands + the link back itself. It is deliberately not logged: a sign-in link in a + log file is a credential in a log file. """ if not settings.email_configured: - log.info("sign-in link for %s: %s", to, url) return False minutes = settings.login_link_minutes diff --git a/backend/settings.py b/backend/settings.py index 8911388..4583922 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -79,8 +79,7 @@ class Settings(BaseSettings): stripe_webhook_secret: str = "" # Sign-in links go out over plain SMTP, so any provider works. With no - # host set the link is written to the server log instead - fine for - # localhost, and it keeps sign-in testable without an email account. + # host set, sign-in is simply unavailable (see dev_login_links below). smtp_host: str = "" smtp_port: int = 587 smtp_user: str = "" @@ -88,6 +87,10 @@ class Settings(BaseSettings): smtp_from: str = "SubRead <login@subread.space>" smtp_starttls: bool = True login_link_minutes: int = 30 + # Hand the sign-in link back in the API response instead of mailing it. + # For development ONLY: it signs anyone in as any address. Never inferred + # from other settings - a public server with billing off is still public. + dev_login_links: bool = False # --- match check ------------------------------------------------------- # Transcribe a few short samples on upload and score them against the book, @@ -119,6 +122,10 @@ class Settings(BaseSettings): def email_configured(self) -> bool: return bool(self.smtp_host) + @property + def sign_in_available(self) -> bool: + return self.email_configured or self.dev_login_links + @property def resolved_database_url(self) -> str: if self.database_url: diff --git a/tests/test_auth.py b/tests/test_auth.py index f8147d4..c7d388d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -117,16 +117,21 @@ def test_requests_are_rate_limited(client, outbox): assert codes[:5] == [200] * 5 and set(codes[5:]) == {429} -def test_public_server_without_smtp_refuses_instead_of_leaking_the_link(client): - # billing on (public) + no SMTP host: showing the link would let anyone - # sign in as anyone. +@pytest.mark.parametrize("billing", [True, False]) +def test_server_without_smtp_refuses_instead_of_leaking_the_link( + client, monkeypatch, billing +): + # No mail server: showing the link instead would let anyone sign in as + # anyone. Billing being off must not soften this - a public server can + # perfectly well have billing off. + monkeypatch.setattr(settings, "billing_enabled", billing) r = client.post("/api/auth/request", json={"email": "x@example.com"}) - assert r.status_code == 503 + assert r.status_code == 503 and "dev_link" not in r.text assert client.get("/api/account").json()["email_sign_in_available"] is False -def test_localhost_hands_the_link_back(client, monkeypatch): - monkeypatch.setattr(settings, "billing_enabled", False) +def test_dev_mode_hands_the_link_back_only_when_asked_to(client, monkeypatch): + monkeypatch.setattr(settings, "dev_login_links", True) r = client.post("/api/auth/request", json={"email": "dev@example.com"}) assert r.status_code == 200 and r.json()["sent"] is False link = r.json()["dev_link"]