Skip to content

Commit 3f7069a

Browse files
fix(relay): stop mail prescanners burning trial magic links; rate-limit the last open routes
Opening a trial magic link no longer redeems it. GET /license/v1/start-trial/verify now renders a confirmation page and the grant happens on the POST its button sends. Corporate mail gateways and antivirus link-prescanners (Outlook Safe Links, Proofpoint URL Defense) GET every URL in an email before the recipient sees it. While a bare GET redeemed the token, a prescanner silently burned the one-time grant and the human who then clicked got "this link is invalid or has already been used" on a completely legitimate first attempt — worst at exactly the corporate mail estates most likely to be evaluating Team. The GET is read-only and never deletes a lapsed row, so a prescanner cannot destroy what it cannot redeem. The token stays in the query string, so no request body is parsed and no python-multipart dependency is involved. The confirm form posts to a query-only relative reference so it resolves against the path the page was actually served from. validate_cloud_base_url PRESERVES the path component, so ENGRAPHIS_RELAY_PUBLIC_URL=https://example.com/relay is valid config, and a root-absolute action would have rendered fine and then posted to a 404 — stranding the customer with a token that expires in 30 minutes and no diagnostic. GET /verify/{key_id} and both /start-trial/verify handlers now share the /register + /team-invite per-IP burst budget. These were the remaining unauthenticated relay routes with no limit at all. The trial-verify pair matters most: both touch SQLite, and the POST takes BEGIN IMMEDIATE — a write lock on the same relay.db that carries seat claims and sync bundles — before it can tell the token is junk. Every /start-trial/verify response (success, each error, and the 429) now sends Cache-Control: no-store and Referrer-Policy: no-referrer from one shared constant. The request URL carries the one-time token, so the error pages are as Referer-leaky as the success page that holds the key; they had been using separate inline literals and drifted. Email copy and the start_team_trial docstring updated to match the button flow. Offline gate green: 1062 tests, ruff clean, both eval harnesses 1.000, ablation discriminative.
1 parent 00a106c commit 3f7069a

4 files changed

Lines changed: 427 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,29 @@ Follow-up audit of the Team / Pro / licensing / relay surfaces after 0.9.8.
3434
unmappable — a permanent 5xx loop risks the endpoint being disabled, which would then
3535
drop real `order.paid` fulfillments.
3636

37+
- Opening a trial magic link no longer redeems it. `GET /license/v1/start-trial/verify`
38+
now renders a confirmation page and the grant happens on the `POST` its button sends.
39+
Corporate mail gateways and antivirus link-prescanners (Outlook Safe Links, Proofpoint
40+
URL Defense) GET every URL in an email before the recipient sees it, which silently
41+
burned the one-time grant and left a legitimate first attempt looking "already used" —
42+
worst at exactly the corporate mail estates most likely to be evaluating Team. The GET
43+
is read-only: it never deletes a lapsed row, so a prescanner cannot destroy what it
44+
cannot redeem. The token stays in the query string, so no request body is parsed and no
45+
multipart dependency is involved.
46+
The confirm form posts to a query-only relative reference, so it resolves against the
47+
path the page was actually served from — `ENGRAPHIS_RELAY_PUBLIC_URL` may legitimately
48+
carry a path (`validate_cloud_base_url` preserves it), and a root-absolute action would
49+
have rendered fine and then posted to a 404.
50+
- `GET /license/v1/verify/{key_id}` and both `/license/v1/start-trial/verify` handlers now
51+
share the `/register` + `/team-invite` per-IP burst budget. These were the remaining
52+
unauthenticated relay routes with no limit; the trial-verify pair matters most, since
53+
both touch SQLite — and the POST takes `BEGIN IMMEDIATE` on the same `relay.db` that
54+
carries seat claims and sync bundles — before they can tell the token is junk.
55+
- Every `/start-trial/verify` response (success, each error, and the 429) sends
56+
`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The request URL carries
57+
the one-time token, so the error pages are as Referer-leaky as the success page that
58+
holds the key; they previously used separate inline header literals and had drifted.
59+
3760
### Changed
3861

3962
- `GET /api/auth/users` checks `admin` at the route, matching `auth.min_role()`. The

engraphis/inspector/license_cloud.py

Lines changed: 156 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,20 @@ def _claim():
270270

271271

272272
@router.get("/verify/{key_id}")
273-
async def verify(key_id: str):
273+
async def verify(key_id: str, request: Request):
274274
"""Public status probe for a key fingerprint (no key material needed)."""
275+
# Shares the /register + /team-invite burst budget. One indexed SELECT is far cheaper
276+
# than the Ed25519 verify that budget was sized for, and key_id is a SHA-256
277+
# fingerprint so there is nothing to enumerate — but "cheap" is not "free", and an
278+
# unmetered public endpoint on the same SQLite file as the relay is not worth keeping
279+
# as a deliberate exception. Sharing rather than adding a bucket is safe here because
280+
# no client polls this route (only scripts/smoke_cloud.py), so there is no legitimate
281+
# high-frequency caller to starve. The same budget covers both /start-trial/verify
282+
# handlers, which are the genuinely expensive unauthenticated routes here.
283+
if not _register_rate_ok(_register_rate_key(request)):
284+
return JSONResponse(
285+
{"error": "too many verification probes — try again shortly"},
286+
status_code=429, headers={"Retry-After": "60"})
275287
conn = reg.connect()
276288
try:
277289
row = conn.execute(
@@ -822,6 +834,44 @@ def _trial_verify_error_html(message: str) -> str:
822834
</body></html>"""
823835

824836

837+
def _trial_confirm_html(token: str, plan: str, email: str) -> str:
838+
"""The interstitial a human sees before the trial is actually granted.
839+
840+
Exists so that redemption needs a deliberate POST rather than the bare GET an email
841+
link-prescanner performs on the recipient's behalf — see :func:`confirm_team_trial`.
842+
The form posts back to this same URL with the token in the query string, so there is
843+
no request body to parse."""
844+
import html as _html
845+
label = _html.escape(plan.title())
846+
# A QUERY-ONLY relative reference, deliberately: it resolves against the current
847+
# document's own path, so the form posts back to wherever this page was actually
848+
# served from. A root-absolute "/license/v1/start-trial/verify?..." would silently
849+
# break every sub-path deployment — validate_cloud_base_url PRESERVES the path
850+
# component (it only rejects query/fragment), so ENGRAPHIS_RELAY_PUBLIC_URL=
851+
# https://example.com/relay is valid config; the emailed link would render fine and
852+
# then POST to a 404, stranding the customer with a token that expires in 30 minutes
853+
# and no diagnostic. Same breakage behind a root_path reverse proxy.
854+
# The token goes into an attribute, so escape it with quote=True (the default).
855+
action = "?token=%s" % _html.escape(token)
856+
return f"""<!doctype html><html><head><meta charset="utf-8">
857+
<meta name="viewport" content="width=device-width,initial-scale=1">
858+
<meta name="robots" content="noindex,nofollow">
859+
<title>Confirm your Engraphis {label} trial</title></head>
860+
<body style="font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px">
861+
<h2>Confirm your {label} trial</h2>
862+
<p>You're one click away. This will activate a trial for
863+
<strong>{_html.escape(email)}</strong>.</p>
864+
<form method="post" action="{action}">
865+
<button type="submit" style="font:inherit;font-weight:600;padding:12px 20px;border:0;
866+
border-radius:8px;background:#5941c2;color:#fff;cursor:pointer">
867+
Activate my {label} trial</button>
868+
</form>
869+
<p style="color:#666;font-size:13px;margin-top:24px">This link can only be used once.
870+
If you didn't request a trial, you can ignore this page — nothing happens until you
871+
click the button.</p>
872+
</body></html>"""
873+
874+
825875
def _reserve_trial(mid: str, email: str, plan: str) -> Optional[str]:
826876
"""Reserve the newest pending magic link without blocking the event loop."""
827877
conn = reg.connect()
@@ -878,7 +928,9 @@ async def start_team_trial(request: Request):
878928
879929
Does NOT issue a key synchronously (see the 2026-07-14 module comment above): it
880930
emails a one-time magic link to *email* and mints the real signed key only when
881-
that link is opened (:func:`verify_team_trial`, the ``GET`` companion below).
931+
that link is CONFIRMED. Opening it (``GET``) only renders :func:`confirm_team_trial`'s
932+
page; the key is minted by :func:`verify_team_trial`, the ``POST`` that page's button
933+
sends — so a mail link-prescanner cannot burn the grant on the recipient's behalf.
882934
``plan`` selects the tier ("pro" or "team", default "team"). 429 if this source IP
883935
has requested too many trials recently (``ENGRAPHIS_TRIAL_RATE_LIMIT_PER_HOUR``,
884936
default 5/hour); 400 for a missing machine_id, an unknown plan, or a missing/
@@ -948,16 +1000,106 @@ async def start_team_trial(request: Request):
9481000
"expires_in": _TRIAL_TOKEN_TTL_SECONDS}
9491001

9501002

1003+
#: Applied to EVERY /start-trial/verify response, not just the success page: the request
1004+
#: URL itself carries the one-time token, so even an error page must stay out of shared
1005+
#: caches and out of the Referer of anything the reader clicks from there.
1006+
_TRIAL_PAGE_HEADERS = {
1007+
"Cache-Control": "no-store, no-cache, must-revalidate, private",
1008+
"Pragma": "no-cache",
1009+
"Referrer-Policy": "no-referrer",
1010+
}
1011+
1012+
1013+
def _trial_verify_rate_limited(request: Request) -> Optional[HTMLResponse]:
1014+
"""Shared burst gate for both /start-trial/verify handlers, or None when allowed.
1015+
1016+
Both are unauthenticated and both hit SQLite before they can know whether the token
1017+
is even real: the GET runs connect + two executescripts + a PRAGMA + a SELECT, and
1018+
the POST additionally takes BEGIN IMMEDIATE — a RESERVED write lock on the same
1019+
relay.db that carries seat claims and sync bundles — BEFORE it discovers the token is
1020+
garbage. Flooding either one with junk tokens is therefore a bigger lever than the
1021+
single indexed SELECT behind /verify/{key_id}. Both are sync defs, so each request
1022+
also pins a threadpool worker. Answers HTML, not JSON: these routes are opened by a
1023+
human in a browser."""
1024+
if _register_rate_ok(_register_rate_key(request)):
1025+
return None
1026+
return HTMLResponse(
1027+
_trial_verify_error_html("Too many attempts — please wait a minute and retry."),
1028+
status_code=429, headers=dict(_TRIAL_PAGE_HEADERS, **{"Retry-After": "60"}))
1029+
1030+
9511031
@router.get("/start-trial/verify")
952-
def verify_team_trial(token: str = ""):
1032+
def confirm_team_trial(request: Request, token: str = ""):
1033+
"""Render the confirmation page for a magic-link token — WITHOUT redeeming it.
1034+
1035+
Redemption lives in the POST companion below, and this split is load-bearing rather
1036+
than cosmetic. Corporate mail gateways and antivirus link-prescanners (Outlook Safe
1037+
Links, Proofpoint URL Defense, and friends) routinely GET every URL they find in an
1038+
email body before the recipient ever sees it. When a bare GET redeemed the token, a
1039+
prescanner silently burned the one-time grant, and the human who then clicked the
1040+
link got "this link is invalid or has already been used" on a completely legitimate
1041+
first attempt — and it hit hardest at exactly the corporate mail estates most likely
1042+
to be buying Team.
1043+
1044+
So: GET is safe and idempotent (it only reads), and the actual grant happens on a
1045+
POST that a human has to click a button to send. Prescanners do not submit forms.
1046+
The token still rides in the query string, which is what the form posts back to, so
1047+
no request body parsing — and therefore no multipart dependency — is involved."""
1048+
limited = _trial_verify_rate_limited(request)
1049+
if limited is not None:
1050+
return limited
1051+
token = (token or "").strip()
1052+
if not token:
1053+
return HTMLResponse(_trial_verify_error_html("Missing token."),
1054+
status_code=400, headers=_TRIAL_PAGE_HEADERS)
1055+
1056+
conn = reg.connect()
1057+
try:
1058+
conn.executescript(_TRIAL_SCHEMA)
1059+
_ensure_trial_plan_column(conn)
1060+
conn.executescript(_TRIAL_PENDING_SCHEMA)
1061+
row = conn.execute(
1062+
"SELECT machine_id, email, plan, expires_at FROM trial_pending "
1063+
"WHERE token_hash=?", (_hash_token(token),)).fetchone()
1064+
finally:
1065+
conn.close()
1066+
1067+
# Mirror the POST's diagnostics so a user learns the real problem before clicking,
1068+
# not after. Read-only: a lapsed row is left for the retention sweep in
1069+
# _reserve_trial, never deleted here (deleting on GET would hand a prescanner a way
1070+
# to destroy the row it cannot redeem).
1071+
if row is None:
1072+
return HTMLResponse(
1073+
_trial_verify_error_html("This link is invalid or has already been used."),
1074+
status_code=400, headers=_TRIAL_PAGE_HEADERS)
1075+
if row["expires_at"] < time.time():
1076+
return HTMLResponse(
1077+
_trial_verify_error_html(
1078+
"This link has expired — request a new trial from the dashboard."),
1079+
status_code=400, headers=_TRIAL_PAGE_HEADERS)
1080+
1081+
return HTMLResponse(_trial_confirm_html(token, row["plan"], row["email"]),
1082+
headers=_TRIAL_PAGE_HEADERS)
1083+
1084+
1085+
@router.post("/start-trial/verify")
1086+
def verify_team_trial(request: Request, token: str = ""):
9531087
"""Redeem a magic-link token from :func:`start_team_trial` — mints and displays the
9541088
real signed trial key. Answers a small HTML page, not JSON: this is meant to be
955-
opened directly from the confirmation email by a human, who needs to read and copy
956-
a key, not parse a response body. One-time: the token is deleted on first use
957-
(success OR a stale/losing race), so replaying a link never mints twice."""
1089+
reached by a human clicking the confirm button on the GET page above, who needs to
1090+
read and copy a key, not parse a response body. One-time: the token is deleted on
1091+
first use (success OR a stale/losing race), so replaying it never mints twice.
1092+
1093+
Takes the token from the QUERY STRING, not a form body: the GET page posts back to
1094+
its own URL, so there is nothing to parse and no python-multipart dependency (which
1095+
has broken the [server] extra before)."""
1096+
limited = _trial_verify_rate_limited(request)
1097+
if limited is not None:
1098+
return limited
9581099
token = (token or "").strip()
9591100
if not token:
960-
return HTMLResponse(_trial_verify_error_html("Missing token."), status_code=400)
1101+
return HTMLResponse(_trial_verify_error_html("Missing token."),
1102+
status_code=400, headers=_TRIAL_PAGE_HEADERS)
9611103

9621104
conn = reg.connect()
9631105
try:
@@ -978,14 +1120,14 @@ def verify_team_trial(token: str = ""):
9781120
return HTMLResponse(
9791121
_trial_verify_error_html(
9801122
"This link is invalid or has already been used."),
981-
status_code=400)
1123+
status_code=400, headers=_TRIAL_PAGE_HEADERS)
9821124
if row["expires_at"] < now:
9831125
conn.execute("DELETE FROM trial_pending WHERE token_hash=?", (token_hash,))
9841126
conn.execute("COMMIT")
9851127
return HTMLResponse(
9861128
_trial_verify_error_html(
9871129
"This link has expired — request a new trial from the dashboard."),
988-
status_code=400)
1130+
status_code=400, headers=_TRIAL_PAGE_HEADERS)
9891131
mid, email, plan = row["machine_id"], row["email"], row["plan"]
9901132
existing = conn.execute(
9911133
"SELECT 1 FROM trial_grants WHERE machine_id=?", (mid,)).fetchone()
@@ -996,7 +1138,7 @@ def verify_team_trial(token: str = ""):
9961138
return HTMLResponse(
9971139
_trial_verify_error_html(
9981140
"The free trial has already been used on this device."),
999-
status_code=409)
1141+
status_code=409, headers=_TRIAL_PAGE_HEADERS)
10001142
from engraphis.inspector.webhooks import issue_key
10011143
from engraphis.licensing import TRIAL_DAYS
10021144
seats = TEAM_TRIAL_SEATS if plan == "team" else 1
@@ -1025,9 +1167,7 @@ def verify_team_trial(token: str = ""):
10251167
pass
10261168
# This body contains the full signed license key and the URL still carries the
10271169
# one-time token, so keep both out of shared caches and out of Referer headers on
1028-
# any link the reader clicks from here.
1029-
return HTMLResponse(_trial_verify_success_html(key, plan, TRIAL_DAYS), headers={
1030-
"Cache-Control": "no-store, no-cache, must-revalidate, private",
1031-
"Pragma": "no-cache",
1032-
"Referrer-Policy": "no-referrer",
1033-
})
1170+
# any link the reader clicks from here. Uses the shared constant rather than an
1171+
# inline copy so the success page and the error pages can never drift apart.
1172+
return HTMLResponse(_trial_verify_success_html(key, plan, TRIAL_DAYS),
1173+
headers=_TRIAL_PAGE_HEADERS)

engraphis/inspector/webhooks.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -639,26 +639,29 @@ def _trial_verify_email_text(verify_url: str, plan: str, minutes: int) -> str:
639639
640640
{verify_url}
641641
642-
Opening it mints your trial key and shows it on a confirmation page, with
643-
instructions to activate it in your dashboard (Settings -> License -> paste key ->
644-
Activate).
642+
Opening it shows a confirmation page with an "Activate my {label} trial" button.
643+
Clicking that button mints your trial key and shows it, with instructions to activate
644+
it in your dashboard (Settings -> License -> paste key -> Activate).
645645
646646
If you didn't request this, you can safely ignore this email: no trial has been
647-
issued, and none will be unless this link is opened.
647+
issued, and none will be unless that button is clicked. Simply opening the link — or
648+
a mail scanner opening it for you — grants nothing.
648649
649650
— The Engraphis team
650651
"""
651652

652653

653654
def send_trial_verification_email(to: str, verify_url: str, plan: str = "team", *,
654655
minutes: int = 30) -> None:
655-
"""Deliver a one-time magic link that mints a self-serve trial key on click.
656+
"""Deliver a one-time magic link that mints a self-serve trial key on confirmation.
656657
657658
Part of the 2026-07-14 trial-abuse hardening: ``inspector.license_cloud``'s
658659
``POST /license/v1/start-trial`` no longer issues a key synchronously from a bare
659660
machine_id (trivially reset by deleting one local file — see that module's
660-
comment); it emails this link instead, and the matching ``GET .../start-trial/
661-
verify`` mints the key only once it's opened. Raises on delivery failure (see
661+
comment); it emails this link instead. Opening the link (``GET .../start-trial/
662+
verify``) only renders a confirm page — the key is minted by the ``POST`` that
663+
page's button sends, so a mail link-prescanner GETting the URL on the recipient's
664+
behalf cannot burn the one-time grant. Raises on delivery failure (see
662665
:func:`_send_text_email`) — unlike the password-reset send above, the caller DOES
663666
let a failure change the HTTP response (502): trial start is opt-in self-serve
664667
(no account to enumerate), and silently swallowing the failure would strand the

0 commit comments

Comments
 (0)