SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

accounts: cookie versionné (session_version), garde CSRF acceptant un corps vide prouvé (X-Requested-With / Sec-Fetch-Site / Origin), coût constant du mot de passe, courriels différés après commit (outbox) et jamais de lien dans les logs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent 61d6091

2 changed files +216 −33

modified hfmarketdata/api/accounts/mailer.py +141 −12
@@ -1,9 +1,14 @@
1 1 """Transactional e-mail through the Resend HTTP API (httpx).
2 2
3 3 `send(kind, to, **ctx)` renders one of the English templates (plain text + minimal HTML) and posts it to
4 −Resend when `settings.resend_api_key` is set. Without a key the message is logged and `delivered=False` is
5 −returned, so the caller stores the action link in `email_tokens.meta` for the admin UI / CLI to print.
6 −API keys are NEVER included in an e-mail.
4 +Resend when `settings.resend_api_key` is set. Without a key the message is logged (type + masked recipient,
5 +never the link) and `delivered=False` is returned, so the caller stores the action link in
6 +`email_tokens.meta` for the admin UI / CLI to print. API keys are NEVER included in an e-mail.
7 +
8 +`queue(session, kind, to, **ctx)` defers the delivery until the SQLAlchemy session **commits** (outbox in
9 +`session.info`, flushed by the `after_commit` hook to a small thread pool) so the 10-second Resend call
10 +never runs inside a SQLite transaction and a rolled-back signup sends nothing. `on_failure(kind, to, ctx)`
11 +callbacks let the caller persist the link when the provider rejects the message.
7 12
8 13 Author: Simon-Pierre Boucher <contact@spboucher.ai>
9 14 """
@@ -11,32 +16,67 @@ from __future__ import annotations
11 16
12 17 import html
13 18 import logging
14 −from dataclasses import dataclass
19 +from collections.abc import Callable
20 +from concurrent.futures import ThreadPoolExecutor
21 +from dataclasses import dataclass, field
22 +from typing import Any
15 23
16 24 import httpx
25 +from sqlalchemy import event
26 +from sqlalchemy.orm import Session
17 27
18 28 from core.config import settings
29 +from core.db import SessionLocal
19 30
20 31 log = logging.getLogger("hfmarketdata.mailer")
21 32
22 33 RESEND_URL = "https://api.resend.com/emails"
23 34 TIMEOUT_S = 10.0
35 +OUTBOX_KEY = "hfmd_mail_outbox"
36 +_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="hfmd-mail")
24 37
25 38
26 39 @dataclass(frozen=True)
27 40 class Delivery:
28 41 delivered: bool
29 42 provider_id: str | None = None
30 − error: str | None = None
43 + error: str | None = None # no_provider | queued | resend_<status> | <exception>
44 +
45 + @property
46 + def queued(self) -> bool:
47 + return self.error == "queued"
48 +
49 +
50 +@dataclass
51 +class Outgoing:
52 + kind: str
53 + to: str
54 + ctx: dict[str, Any]
55 + on_failure: Callable[[str, str, dict[str, Any], Delivery], None] | None = None
56 + on_success: Callable[[str, str, dict[str, Any], Delivery], None] | None = field(default=None)
57 +
58 +
59 +def mask(email: str) -> str:
60 + """ada@example.com → a***@example.com (for logs)."""
61 + if not email or "@" not in email:
62 + return "***"
63 + local, _, domain = email.partition("@")
64 + return f"{local[:1]}***@{domain}"
65 +
66 +
67 +def provider_configured() -> bool:
68 + return bool(settings.resend_api_key)
31 69
32 70
33 71 def _template(kind: str, ctx: dict) -> tuple[str, str]:
34 − """Return (subject, body_text) for a template. `ctx` keys: name, link, key_name, key_prefix, expires."""
72 + """Return (subject, body_text) for a template. `ctx` keys: name, link, key_name, key_prefix, expires, …"""
35 73 name = ctx.get("name") or "there"
36 74 link = ctx.get("link", "")
37 75 brand = "HF Market Data"
38 76 footer = (f"\n\n— {brand} · {settings.public_url}\nQuestions? Reply to this e-mail or write to "
39 77 f"{settings.contact_email}.")
78 + keys_url = f"{settings.public_url}/dashboard/keys"
79 + account_url = f"{settings.public_url}/dashboard/account"
40 80 if kind == "verify":
41 81 return (f"Verify your e-mail for {brand}",
42 82 f"Hi {name},\n\nConfirm your e-mail address to activate your {brand} account and create your "
@@ -54,12 +94,63 @@ def _template(kind: str, ctx: dict) -> tuple[str, str]:
54 94 f"Hi {name},\n\nSomeone asked to reset the password of your {brand} account. If it was you, "
55 95 f"choose a new password here:\n\n{link}\n\nThe link is valid for {ctx.get('expires', '1 hour')}. "
56 96 f"Otherwise ignore this e-mail — your password stays unchanged.{footer}")
97 + if kind == "already_registered":
98 + return (f"You already have a {brand} account",
99 + f"Hi {name},\n\nSomeone (probably you) just tried to sign up on {brand} with this e-mail address, "
100 + f"but an account already exists. Nothing was changed.\n\nSign in at {settings.public_url}/signin, or "
101 + f"choose a new password if you forgot it:\n\n{settings.public_url}/reset\n\nIf this was not you, "
102 + f"you can ignore this message.{footer}")
103 + if kind == "email_change":
104 + return (f"Confirm your new e-mail address for {brand}",
105 + f"Hi {name},\n\nConfirm that {ctx.get('new_email', 'this address')} is now the e-mail of your "
106 + f"{brand} account:\n\n{link}\n\nThe link is valid for {ctx.get('expires', '48 hours')}. Until you "
107 + f"confirm, the account keeps its current address. If you did not request this, ignore this "
108 + f"message.{footer}")
109 + if kind == "email_changed":
110 + return (f"Your {brand} e-mail address was changed",
111 + f"Hi {name},\n\nThe e-mail address of your {brand} account was changed to "
112 + f"{ctx.get('new_email', '(new address)')}. Every browser session was signed out.\n\nIf this was not "
113 + f"you, contact {settings.contact_email} right away.{footer}")
114 + if kind == "password_changed":
115 + return (f"Your {brand} password was changed",
116 + f"Hi {name},\n\nThe password of your {brand} account was just changed and every other browser "
117 + f"session was signed out.\n\nIf this was not you, reset your password now at "
118 + f"{settings.public_url}/reset and contact {settings.contact_email}.{footer}")
119 + if kind == "suspicious_login":
120 + return (f"Suspicious sign-in attempts on your {brand} account",
121 + f"Hi {name},\n\nWe blocked repeated failed sign-in attempts on your {brand} account "
122 + f"({ctx.get('attempts', 10)} wrong passwords in a row). The account is temporarily locked; it "
123 + f"unlocks by itself.\n\nIf this was not you, we recommend choosing a new password at "
124 + f"{settings.public_url}/reset and reviewing your API keys at {keys_url}.{footer}")
57 125 if kind == "key_created":
58 126 return (f"New API key on your {brand} account",
59 127 f"Hi {name},\n\nA new API key named “{ctx.get('key_name', 'default')}” "
60 128 f"(prefix {ctx.get('key_prefix', '')}…) was created on your account. For security the key "
61 129 f"itself is only shown once, in the dashboard.\n\nIf this was not you, revoke it now: "
62 − f"{settings.public_url}/dashboard/keys{footer}")
130 + f"{keys_url}{footer}")
131 + if kind == "key_revoked":
132 + return (f"API key revoked on your {brand} account",
133 + f"Hi {name},\n\nThe API key “{ctx.get('key_name', 'default')}” (prefix {ctx.get('key_prefix', '')}…) "
134 + f"was revoked. Requests using it now get 401 INVALID_API_KEY.\n\nIf this was not you, sign in and "
135 + f"check your account: {keys_url}{footer}")
136 + if kind == "key_rotated":
137 + return (f"API key rotated on your {brand} account",
138 + f"Hi {name},\n\nThe API key “{ctx.get('key_name', 'default')}” (prefix {ctx.get('key_prefix', '')}…) "
139 + f"was rotated: the old key is revoked and a new one (prefix {ctx.get('new_prefix', '')}…) replaces "
140 + f"it. The new key was shown once in the dashboard.\n\nIf this was not you, revoke it now: "
141 + f"{keys_url}{footer}")
142 + if kind == "account_deleted":
143 + return (f"Your {brand} account was deleted",
144 + f"Hi {name},\n\nYour {brand} account was deleted as requested: every API key is revoked and every "
145 + f"session signed out. Aggregated usage counters are kept anonymously.\n\nThank you for using "
146 + f"{brand}.{footer}")
147 + if kind == "quota_alert":
148 + what = ctx.get("what", "quota")
149 + return (f"{brand}: {what}",
150 + f"Hi {name},\n\n{ctx.get('detail', '')}\n\nSee your live counters at {settings.public_url}/dashboard "
151 + f"and the tier table at {settings.public_url}/limits. Higher limits are free on request: "
152 + f"{settings.contact_email}.\n\nYou receive at most one alert per day; turn them off in {account_url}."
153 + f"{footer}")
63 154 raise ValueError(f"unknown mail template {kind!r}")
64 155
65 156
@@ -80,19 +171,57 @@ def render(kind: str, **ctx) -> tuple[str, str, str]:
80 171
81 172
82 173 def send(kind: str, to: str, **ctx) -> Delivery:
174 + """Synchronous delivery. Logs never contain the action link or the full recipient."""
83 175 subject, text, body_html = render(kind, **ctx)
84 − if not settings.resend_api_key:
85 − log.info("mail not delivered (no HFMD_RESEND_API_KEY) kind=%s to=%s subject=%r link=%s",
86 − kind, to, subject, ctx.get("link"))
176 + if not provider_configured():
177 + log.info("mail not delivered (no HFMD_RESEND_API_KEY) kind=%s to=%s", kind, mask(to))
87 178 return Delivery(False, error="no_provider")
88 179 payload = {"from": settings.mail_from, "to": [to], "subject": subject, "text": text, "html": body_html}
89 180 try:
90 181 r = httpx.post(RESEND_URL, json=payload, timeout=TIMEOUT_S,
91 182 headers={"Authorization": f"Bearer {settings.resend_api_key}"})
92 183 if r.status_code >= 300:
93 − log.warning("resend error %s for %s: %s", r.status_code, to, r.text[:300])
184 + log.warning("resend error %s kind=%s to=%s: %s", r.status_code, kind, mask(to), r.text[:300])
94 185 return Delivery(False, error=f"resend_{r.status_code}")
95 186 return Delivery(True, provider_id=(r.json() or {}).get("id"))
96 187 except Exception as exc:
97 − log.warning("resend unreachable for %s: %s", to, exc)
188 + log.warning("resend unreachable kind=%s to=%s: %s", kind, mask(to), type(exc).__name__)
98 189 return Delivery(False, error=str(exc)[:200])
190 +
191 +
192 +# ------------------------------------------------------------------------------------- deferred sending
193 +
194 +def _deliver(msg: Outgoing) -> None:
195 + try:
196 + d = send(msg.kind, msg.to, **msg.ctx)
197 + cb = msg.on_success if d.delivered else msg.on_failure
198 + if cb is not None:
199 + cb(msg.kind, msg.to, msg.ctx, d)
200 + except Exception as exc: # pragma: no cover — never crash the pool
201 + log.warning("deferred mail %s to %s failed: %s", msg.kind, mask(msg.to), exc)
202 +
203 +
204 +def dispatch(msg: Outgoing) -> None:
205 + """Hand a message to the background pool (after commit). Patched by tests to run inline."""
206 + _pool.submit(_deliver, msg)
207 +
208 +
209 +def queue(s: Session, kind: str, to: str, *, on_failure=None, on_success=None, **ctx) -> Delivery:
210 + """Queue a message to be sent once `s` commits. Returns `no_provider` immediately when Resend is not
211 + configured (nothing will be sent — the caller keeps the link), `queued` otherwise."""
212 + if not provider_configured():
213 + log.info("mail not delivered (no HFMD_RESEND_API_KEY) kind=%s to=%s", kind, mask(to))
214 + return Delivery(False, error="no_provider")
215 + s.info.setdefault(OUTBOX_KEY, []).append(Outgoing(kind, to, dict(ctx), on_failure, on_success))
216 + return Delivery(False, error="queued")
217 +
218 +
219 +@event.listens_for(SessionLocal, "after_commit")
220 +def _flush_outbox(session: Session) -> None:
221 + for msg in session.info.pop(OUTBOX_KEY, []) or []:
222 + dispatch(msg)
223 +
224 +
225 +@event.listens_for(SessionLocal, "after_rollback")
226 +def _drop_outbox(session: Session) -> None:
227 + session.info.pop(OUTBOX_KEY, None)
modified hfmarketdata/api/accounts/security.py +75 −21
@@ -1,5 +1,5 @@
1 1 """Passwords (argon2), API keys (hfmd_live_ + 32 base62, sha256(salt+key) stored), e-mail tokens,
2 −signed cookie sessions (itsdangerous) and the CSRF guard.
2 +signed cookie sessions (itsdangerous, carrying the user's `session_version`) and the CSRF guard.
3 3
4 4 Author: Simon-Pierre Boucher <contact@spboucher.ai>
5 5 """
@@ -8,6 +8,8 @@ from __future__ import annotations
8 8 import hashlib
9 9 import secrets
10 10 import string
11 +from dataclasses import dataclass
12 +from urllib.parse import urlsplit
11 13
12 14 from argon2 import PasswordHasher
13 15 from argon2.exceptions import VerifyMismatchError
@@ -22,12 +24,17 @@ KEY_BODY_LEN = 32
22 24 KEY_DISPLAY_CHARS = 8
23 25 BASE62 = string.digits + string.ascii_letters
24 26 MIN_PASSWORD_LEN = 10
27 +MAX_PASSWORD_LEN = 256
25 28 SESSION_COOKIE = "hfmd_session"
26 29 SESSION_MAX_AGE_S = 30 * 86400
27 30 MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
31 +CSRF_HEADER = "x-requested-with"
32 +CSRF_HEADER_VALUE = "hfmd"
28 33
29 34 _ph = PasswordHasher()
30 35 _serializer = URLSafeTimedSerializer(settings.secret_key, salt="hfmd-session")
36 +# Verified against unknown e-mails so a login attempt costs the same whether or not the account exists.
37 +DUMMY_HASH = _ph.hash(secrets.token_urlsafe(16))
31 38
32 39
33 40 # ------------------------------------------------------------------------------------------ passwords
@@ -35,6 +42,8 @@ _serializer = URLSafeTimedSerializer(settings.secret_key, salt="hfmd-session")
35 42 def validate_password(password: str) -> None:
36 43 if not password or len(password) < MIN_PASSWORD_LEN:
37 44 raise ApiError(400, "WEAK_PASSWORD", f"Password must be at least {MIN_PASSWORD_LEN} characters.")
45 + if len(password) > MAX_PASSWORD_LEN:
46 + raise ApiError(400, "WEAK_PASSWORD", f"Password must be at most {MAX_PASSWORD_LEN} characters.")
38 47
39 48
40 49 def hash_password(password: str) -> str:
@@ -42,10 +51,9 @@ def hash_password(password: str) -> str:
42 51
43 52
44 53 def verify_password(password: str, password_hash: str | None) -> bool:
45 − if not password_hash:
46 − return False
54 + """Constant-cost check: accounts without a password (invited) are verified against a dummy hash."""
47 55 try:
48 − return _ph.verify(password_hash, password)
56 + return _ph.verify(password_hash or DUMMY_HASH, password or "") and bool(password_hash)
49 57 except VerifyMismatchError:
50 58 return False
51 59 except Exception:
@@ -72,6 +80,11 @@ def looks_like_key(value: str) -> bool:
72 80 return value.startswith(KEY_PREFIX) and len(body) == KEY_BODY_LEN and all(c in BASE62 for c in body)
73 81
74 82
83 +def hash_ip(ip: str) -> str:
84 + """Same formula as the rate-limit `ip:<hash>` principal (16 hex chars)."""
85 + return hashlib.sha256((settings.key_hash_salt + "|ip|" + ip).encode()).hexdigest()[:16]
86 +
87 +
75 88 # --------------------------------------------------------------------------------------- e-mail tokens
76 89
77 90 def generate_token() -> str:
@@ -84,12 +97,18 @@ def hash_token(token: str) -> str:
84 97
85 98 # -------------------------------------------------------------------------------------------- sessions
86 99
87 −def session_value(user_id: int) -> str:
88 − return _serializer.dumps({"uid": int(user_id)})
100 +@dataclass(frozen=True)
101 +class SessionClaims:
102 + user_id: int
103 + version: int # users.session_version at the time the cookie was issued
104 +
89 105
106 +def session_value(user_id: int, version: int = 1) -> str:
107 + return _serializer.dumps({"uid": int(user_id), "sv": int(version)})
90 108
91 −def set_session_cookie(response: Response, user_id: int) -> None:
92 − response.set_cookie(SESSION_COOKIE, session_value(user_id), max_age=SESSION_MAX_AGE_S, httponly=True,
109 +
110 +def set_session_cookie(response: Response, user_id: int, version: int = 1) -> None:
111 + response.set_cookie(SESSION_COOKIE, session_value(user_id, version), max_age=SESSION_MAX_AGE_S, httponly=True,
93 112 samesite="lax", secure=not settings.is_dev, path="/")
94 113
95 114
@@ -97,7 +116,7 @@ def clear_session_cookie(response: Response) -> None:
97 116 response.delete_cookie(SESSION_COOKIE, path="/", httponly=True, samesite="lax", secure=not settings.is_dev)
98 117
99 118
100 −def read_session(request: Request) -> int | None:
119 +def read_session(request: Request) -> SessionClaims | None:
101 120 raw = request.cookies.get(SESSION_COOKIE)
102 121 if not raw:
103 122 return None
@@ -105,21 +124,56 @@ def read_session(request: Request) -> int | None:
105 124 data = _serializer.loads(raw, max_age=SESSION_MAX_AGE_S)
106 125 except (BadSignature, SignatureExpired):
107 126 return None
108 − uid = data.get("uid") if isinstance(data, dict) else None
109 − return int(uid) if isinstance(uid, int) else None
127 + if not isinstance(data, dict):
128 + return None
129 + uid, sv = data.get("uid"), data.get("sv", 1) # cookies issued before session_version existed count as version 1
130 + if not isinstance(uid, int) or isinstance(uid, bool):
131 + return None
132 + return SessionClaims(uid, int(sv) if isinstance(sv, int) else 1)
110 133
111 134
112 135 # ------------------------------------------------------------------------------------------------ CSRF
113 136
114 −def csrf_guard(request: Request) -> None:
115 − """State-changing account requests must be JSON (`Content-Type: application/json`).
137 +def _same_origin(request: Request) -> bool:
138 + """`Origin` (or `Referer`) host matches the request `Host` or the configured public URL."""
139 + origin = request.headers.get("origin") or request.headers.get("referer") or ""
140 + if not origin or origin == "null":
141 + return False
142 + host = (urlsplit(origin).netloc or "").lower()
143 + if not host:
144 + return False
145 + own = {(request.headers.get("host") or "").lower(), (urlsplit(settings.public_url).netloc or "").lower()}
146 + return host in own
116 147
117 − Combined with SameSite=Lax HttpOnly cookies this defeats form-based CSRF: browsers cannot send a
118 − cross-site request with that content type without a CORS preflight, and credentials are never
119 − allowed cross-origin by our CORS policy.
148 +
149 +def _body_is_empty(request: Request) -> bool:
150 + if request.headers.get("transfer-encoding"):
151 + return False
152 + return (request.headers.get("content-length") or "0").strip() in ("", "0")
153 +
154 +
155 +def csrf_guard(request: Request) -> None:
156 + """State-changing account requests must prove they come from our own front-end or from a non-browser client.
157 +
158 + Accepted proofs (any one is enough):
159 + * `Content-Type: application/json` — a cross-site form cannot produce it without a CORS preflight, and our
160 + CORS policy never allows credentials;
161 + * an **empty body** together with the custom header `X-Requested-With: hfmd` (custom headers trigger a
162 + preflight too), `Sec-Fetch-Site: same-origin` (set by the browser, unforgeable) or an `Origin`/`Referer`
163 + matching our own host.
164 + Anything else → `415 UNSUPPORTED_MEDIA_TYPE`. Combined with SameSite=Lax HttpOnly cookies this defeats
165 + form-based CSRF while letting bodiless POST/DELETE (logout, revoke, rotate) through.
120 166 """
121 − if request.method in MUTATING:
122 − ctype = request.headers.get("content-type", "").split(";")[0].strip().lower()
123 − if ctype != "application/json":
124 − raise ApiError(415, "UNSUPPORTED_MEDIA_TYPE",
125 − "Send `Content-Type: application/json` (use `{}` as body when there is nothing to send).")
167 + if request.method not in MUTATING:
168 + return
169 + ctype = request.headers.get("content-type", "").split(";")[0].strip().lower()
170 + if ctype == "application/json":
171 + return
172 + if _body_is_empty(request) and (
173 + request.headers.get(CSRF_HEADER, "").strip().lower() == CSRF_HEADER_VALUE
174 + or request.headers.get("sec-fetch-site", "").strip().lower() == "same-origin"
175 + or _same_origin(request)
176 + ):
177 + return
178 + raise ApiError(415, "UNSUPPORTED_MEDIA_TYPE",
179 + "Send `Content-Type: application/json` (use `{}` as body when there is nothing to send).")
126 180