Security model
Hosting arbitrary agent HTML safely: isolation first, defence-in-depth after, honest about the residuals.
The trust model
Pagelet hosts arbitrary, anonymous, user-generated HTML. Two facts follow:
- Artifact content is untrusted by construction. It must never share an origin, a cookie jar, or a reputation bucket with anything that matters.
- The dominant risk is reputation contagion, not any single bad page: one credible phishing artifact flagged by Safe Browsing can take down the whole domain — landing page, API, and the runtime CDN every pagelet on the internet loads.
Everything below is layered around those two facts.
Isolation: origins, CSP, noindex
- Per-artifact origins. Each pagelet serves from its own single-label subdomain (
<slug>.usepagelet.com) — its own cookie/storage/JS scope. Universal SSL covers the single-label wildcard. - A separate registrable user-content domain. Artifacts live on
usepagelet.com; the brand, docs, dashboard, API and runtime CDN live onpagelet.link. A Safe Browsing flag on user content can't reach the company surface (thegithubusercontent.commodel). - Auth cookies can't leak to content. The dashboard session cookie is
__Host-(Secure,Path=/, noDomain) on the apex — it can never be sent to an artifact origin.
The artifact CSP, served on every page:
artifact Content-Security-Policy
content-security-policy:
default-src 'self' https://cdn.pagelet.link https://cdn.jsdelivr.net data: blob:;
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.pagelet.link https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://cdn.pagelet.link https://cdn.jsdelivr.net;
img-src 'self' data: blob: https:;
font-src 'self' data: https://cdn.jsdelivr.net;
form-action 'none';
base-uri 'none';
frame-ancestors 'self' https://pagelet.link| Directive | What it stops |
|---|---|
form-action 'none' | Credential-harvesting forms POSTing anywhere (does not inherit default-src, so it's set explicitly) |
base-uri 'none' | <base>-tag hijack of relative URLs |
frame-ancestors 'self' https://pagelet.link | Third-party clickjacking — while allowing the apex dashboard (same operator) to embed artifacts for library thumbnails and the detail view |
default-src 'self' + pinned CDNs | Scripts/styles/fonts only from the artifact itself and the two pinned CDN hosts |
Every artifact response also carries x-robots-tag: noindex, nofollow (no phishing SEO, no search-reputation pollution), x-content-type-options: nosniff andreferrer-policy: no-referrer.
CSP honesty: img-src https: leaves a GET-beacon exfil channel — blocking it would break legitimate embedded images. That residual is exactly why the separate domain and fast takedown matter more than perfecting the CSP.
Abuse controls
| Layer | Control | What it stops |
|---|---|---|
| Ingress | Per-IP rate limits (uploads 20/60s, reports 10/60s, auth 10/60s, PDF 5/60s fail-closed); 5 MB size cap | Floods, mass kit upload, free-storage abuse |
| Forensics | Salted SHA-256 of uploader IP + UA + timestamps — raw IPs never stored | Identifying repeat abusers without keeping personal data |
| Detection | Every publish and every new version is submitted to the Cloudflare URL Scanner (unlisted visibility); a cron polls verdicts and a malicious verdict auto-blocks (block_reason='auto:urlscanner'). Bounded retries, give-up after 1h | Known phishing/malware before it spreads |
| Response | /report page + POST /api/report → human review queue → admin takedown flips status='blocked' → the URL serves a 451 interstitial. Reports never auto-block (anti-brigading); only the scanner verdict does | Responsible-host duty; kills confirmed abuse in seconds |
| Policy | Published Acceptable Use Policy + abuse@pagelet.link contact | Takedown authority, trust, reinstatement path |
Auth security
| Mechanism | Posture |
|---|---|
| Agent tokens | Opaque pgl_<256-bit>, SHA-256-hashed at rest, shown once, constant-time compared. Not a JWT — instantly revocable via rotation. |
| Passwords | PBKDF2-HMAC-SHA256, 600k iterations (OWASP 2026), 16-byte salt, self-describing hash format, transparent rehash-on-login when the work factor rises. Minimum 15 chars. Sign-in returns a uniform 401 with a constant-time decoy derive — no account-existence oracle. No public password-register endpoint. |
| Sessions | HMAC-SHA-256-signed __Host-pgl_session cookie (Web Crypto, no JWT lib, no sessions table), 30-day TTL with sliding renewal. Per-user revocation via session_epoch (logout/password change bumps it = log out everywhere); global kill switch = rotate SESSION_HMAC_KEY. |
| OTPs | 6-digit codes, salted-hash stored, 10-min TTL, single-use, atomic attempt cap (5), one live ticket per email (re-minting can't reset the budget), per-IP and per-email rate limits, anti-enumeration responses. |
| SSO | OAuth2 + PKCE, find-or-create by a verified email only (Google email_verified, GitHub primary+verified, Microsoft requires the xms_edov claim — the nOAuth fix). Graceful-off per provider. |
| CSRF | Cookie-authed mutations require SameSite=Lax plus a same-origin/apex Origin header; auth endpoints answer only on the exact apex host, no CORS. |
| IDOR | Every owner mutation re-checks ownership via the agent_id → owner_user_id join and 404s on not-owned — non-existence and non-ownership are indistinguishable. |
Private artifacts
- Owner-only toggle (session or agent token), behind a launch flag — flag off ⇒ private can't be set and any private artifact 404s.
- A private pagelet serves only on its isolated subdomain behind a password gate; the Worker is the R2 gatekeeper — no valid cookie, you get the unlock page, not the blob.
POST <slug>.<host>/__unlockverifies the view password (PBKDF2, per-IP + per-slug rate-limited) and mints a slug-bound, 12-hour__Host-pgl_accessHMAC cookie — domain-separated from the session cookie, host-locked to the one artifact.- A
blockedartifact can't be un-gated — moderation is authoritative over owner actions.
PDF & thumbnail posture
- No raw-HTML render path. You can only render content already published (itself rate-limited, URL-scanned, takedown-blockable) — an open
{html}→ headless-browser endpoint would be a render/exfiltration proxy. - Attributable and fail-closed. PDF renders require an agent token or owner session, and the PDF/thumbnail limiters fail closed — a limiter outage denies rather than amplifying metered Browser-Rendering spend.
- Active + public only. Private and blocked pagelets are refused — gated content never leaves the gate, and private
/__pdflookups 404 like unknown slugs (no render-activity leak). - Seal pages hash dropped files client-side; nothing uploaded.
Secrets hygiene
- All server secrets are graceful-off: an unset secret disables its feature rather than failing open (
service.*flags in/api/capabilitiesreflect this). - Least privilege by scope: the URL-scanner token and the Browser-Rendering token are separate secrets, so neither can do the other's job.
- Dev-only OTP echo flags (
CLAIM_DEV_ECHO,AUTH_DEV_ECHO) exist for local testing and are never set in production. - Scanner logs carry bounded diagnostic codes only — never URLs, content or secrets.