Pagelet/ docs
Docs menu

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:

  1. Artifact content is untrusted by construction. It must never share an origin, a cookie jar, or a reputation bucket with anything that matters.
  2. 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 on pagelet.link. A Safe Browsing flag on user content can't reach the company surface (the githubusercontent.com model).
  • Auth cookies can't leak to content. The dashboard session cookie is __Host- (Secure, Path=/, no Domain) 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
DirectiveWhat 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.linkThird-party clickjacking — while allowing the apex dashboard (same operator) to embed artifacts for library thumbnails and the detail view
default-src 'self' + pinned CDNsScripts/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

LayerControlWhat it stops
IngressPer-IP rate limits (uploads 20/60s, reports 10/60s, auth 10/60s, PDF 5/60s fail-closed); 5 MB size capFloods, mass kit upload, free-storage abuse
ForensicsSalted SHA-256 of uploader IP + UA + timestamps — raw IPs never storedIdentifying repeat abusers without keeping personal data
DetectionEvery 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 1hKnown 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 doesResponsible-host duty; kills confirmed abuse in seconds
PolicyPublished Acceptable Use Policy + abuse@pagelet.link contactTakedown authority, trust, reinstatement path

Auth security

MechanismPosture
Agent tokensOpaque pgl_<256-bit>, SHA-256-hashed at rest, shown once, constant-time compared. Not a JWT — instantly revocable via rotation.
PasswordsPBKDF2-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.
SessionsHMAC-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.
OTPs6-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.
SSOOAuth2 + 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.
CSRFCookie-authed mutations require SameSite=Lax plus a same-origin/apex Origin header; auth endpoints answer only on the exact apex host, no CORS.
IDOREvery 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>/__unlock verifies the view password (PBKDF2, per-IP + per-slug rate-limited) and mints a slug-bound, 12-hour __Host-pgl_access HMAC cookie — domain-separated from the session cookie, host-locked to the one artifact.
  • A blocked artifact 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 /__pdf lookups 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/capabilities reflect 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.