FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
6.9 KiB
Security
Threat model summary
This app fetches user-submitted and discovered URLs from the public internet on a schedule, stores API keys for third-party providers, and sends emails/SMS. The main risks are: SSRF via URL fetching, credential leakage, cross-user data access, and abuse of the scraping/LLM/SMS pipeline as a cost or spam vector.
SSRF prevention (app/core/http.py::safe_fetch)
All outbound collector/custom-URL requests go through one function. It:
- Only allows
http/httpsschemes (nofile://,ftp://,gopher://, etc.). - Resolves DNS itself before connecting, and rejects the request if any resolved address is loopback, link-local, private (RFC1918/RFC4193), multicast, or a known cloud metadata address (
169.254.169.254, etc.). - Disables automatic redirect following in the HTTP client and instead re-validates each redirect target against the same rules before following it, up to a small max-hop count.
- Applies a per-request timeout and a per-domain rate limit / delay (
SCRAPER_DOMAIN_DELAY_SECONDS). - Sends a configurable, identifying
User-Agent(SCRAPER_USER_AGENT) rather than impersonating a browser.
No collector or route handler is allowed to call httpx/aiohttp directly for a user- or discovery-supplied URL — code review should reject that pattern. The guard has zero exceptions: every resolved private/loopback/link-local/metadata address is rejected unconditionally, with no allowlist.
Secrets
- All secrets are read from environment variables via
app/core/config.py. Nothing is hard-coded. .envis git-ignored;.env.exampleships with empty/placeholder values only.- Secrets are never logged. The structured logger has a redaction filter for known secret-shaped keys (
*_key,*_secret,*_token,*password*). - The frontend never receives backend API keys; all third-party calls happen server-side.
NotificationDestination.destination_value(email/phone) is treated as PII, not a secret, but is still excluded from audit logs beyond a masked form.
AuthN/AuthZ
- Passwords hashed with Argon2 (
passlib[argon2]), never stored or logged in plaintext. - JWT access tokens are short-lived (default 15 min); refresh tokens are longer-lived, stored hashed, and rotated on use.
- Every service-layer query that loads a
Company,Report,Alert,Source, orNotificationDestinationfilters byowner_user_id— enforced in the repository layer, not just the route layer, so a missed check in one route can't leak data. - Auth endpoints are rate-limited (
slowapi) to blunt credential stuffing. Per-IP request-rate limits also apply to every other mutation endpoint that creates data or costs money to call — company/source/notification-destination creation, report generation (LLM call), notification-destination test-send (real SMTP/Twilio call), and run-now — on top of (not instead of) the business-logic caps in "Cost/abuse controls" below. See@limiter.limit(...)usages acrossapp/api/v1/*.py. AUTH_MODE=localis for local development only; the app refuses to start in this mode whenAPP_ENV=production.- Notification destinations are not verified.
NotificationDestination.verifiedexists on the model but nothing ever sets it totrue- a destination is usable for real alert sends as soon as it's created, with no confirmation-code/link flow. Combined with SMS being real-money-per-message onceNOTIFICATION_SMS_ENABLED=trueand Twilio is configured, this means a user could point alerts at a phone number or inbox they don't control. Rate limiting on the/testendpoint (above) narrows the abuse window but doesn't close it; a verification flow is the real fix and is tracked inKNOWN_LIMITATIONS.mdas unimplemented.
Ethical/legal collection rules
Enforced by the collector layer, not left to convention:
robots.txtis fetched and honored before crawling a domain (website collector).- No collector attempts to bypass a CAPTCHA, login wall, paywall, or anti-bot control. If a source requires that, the
Source.statusis set toAUTH_REQUIRED/BLOCKED_BY_POLICYand the failure is recorded, never silently skipped or faked. - Only publicly reachable URLs are collected —
safe_fetchalso serves as the enforcement point for "no private/internal targets." - Per-domain delay + retry-with-backoff avoids hammering a source.
- Content hashing avoids redundant re-fetches of unchanged pages.
- A run that partially fails is marked
partial, not silently reported as complete — the report generator is told which sources failed so it doesn't imply completeness it doesn't have.
Input validation & injection
- All request/response bodies are Pydantic models with explicit types and length limits.
- SQLAlchemy ORM with parameter binding throughout — no raw string-interpolated SQL.
- LLM output is only ever deserialized into a strict Pydantic schema (
generate_structured); free-text LLM output is never concatenated into a shell command, SQL string, or HTML without escaping, and is rendered in the frontend as text, notdangerouslySetInnerHTML. - Request bodies are size-limited at the ASGI layer.
Cost/abuse controls
Per-user limits (configurable, enforced in services/): max monitored companies, max manual "run now" triggers per day, max pages crawled per run (MAX_PAGES_PER_DOMAIN), max LLM tokens per request, max LLM retries, monthly SMS cap. Paid providers (Anthropic, Brave, Twilio) are opt-in via explicit env configuration and default to their Mock/Console counterparts, so a fresh checkout cannot incur cost by accident, and automated tests never hit a paid provider (they run under LLM_PROVIDER=mock, SEARCH_PROVIDER=mock, NOTIFICATION console-only fixtures).
Observability & data retention
- Every HTTP request and every Celery task run carries a correlation id (
request_idfor HTTP,task_idfor Celery, plusrun_idfor monitoring runs) bound into structlog's contextvars for the duration of the request/task, so every log line emitted while handling it can be grepped together - seeapp/main.py::correlation_id_middlewareand thebound_contextvars(...)calls inapp/tasks/*.py. The HTTP middleware also echoes the id back as anX-Request-IDresponse header, reusing an inbound one from a gateway if present. DATA_RETENTION_DAYS(default 365) is enforced by a daily Celery Beat task (app.tasks.maintenance.purge_expired_data, 3am UTC) that deletesSourceDocumentrows - the raw collected text - older than the window. OnlySourceDocumentis in scope: nothing else in the schema has a foreign key onto it, so this can never cascade-delete aSnapshot,DetectedChange,Alert, orReporta user might still want to see (see the docstring onSourceDocumentRepository.delete_older_than).
Reporting a vulnerability
This is a local/dev-stage project; if you find an issue, open an issue in the repository describing the problem and reproduction steps rather than exploiting it further.