Aller au contenu principal

Embed + hosting — threat model

The embed snippet and the per-slug HTML hosting tier (spin-up-as-webapp) share an attack surface — they both let an outsider's bytes either run inside a host page on the public internet, or be served from a Zeq origin. This page collects the threats and the framework's mitigations. The canonical document is outputs/embed-and-host-security.md in the repo.


0. Trust boundaries

+-----------------------------------------------+
| untrusted: host-page DOM, snippet bytes, |
| embed key, third-party referrers |
+-----------------------------------------------+
|
+-----------v-----------+
| embed runtime | <-- CSP-safe, frozen API
+-----------+-----------+
| HTTPS, X-Zsm-Key
+-----------v-----------+
| /api/embed/:slug/... | <-- rate-limited, validated
+-----------+-----------+
|
+-----------v-----------+
| audit log + chain | <-- bytes hashed, never stored (free tier)
+-----------------------+

Rule: all user-facing bytes are untrusted; framework stores hashes/proofs/chain only. Paid hosting tier may store bytes under a separate contract.


1. Embed snippet — threats and mitigations

1.1 Host page tries to read framework data via window.zsm

window.zsm is Object.freezed and Object.defineProperty'd as writable:false, configurable:false. There is no read/get method — only one-way event / state / file / identify / ping / connection. The runtime never reads cookies, localStorage, sessionStorage, IndexedDB, or DOM text. It does not register any input listeners on host elements.

1.2 Malicious site uses someone else's slug+key to spam events

Public keys are visible in the snippet by design. Mitigations:

  • Per-slug rate limit: 60 req/min per IP, 600 req/min per machine, 6,000 req/day per machine.
  • Body-size cap: 4 KB per beacon, 64 KB max payload.
  • Schema validation: kind must be one of {heartbeat, event, state, file, connection, identify, __unload}. Unknown kinds rejected.
  • Origin allow-list per machine (configurable in /portal/embed). Beacons from off-list origins are accepted but flagged cross_origin: true and counted against a separate quota.
  • Public key prefix zsm_pub_… is read-only-public; the owner's zsm_… write-back key NEVER ships.

1.3 XSS payload smuggled in the event data field

The runtime calls JSON.stringify on payload before transmission — there is no HTML rendering on the embed side. On the observer side, all rendered values pass through textContent (never innerHTML) per the project's CSP discipline. Any new render path that uses innerHTML on observer data is a regression.

1.4 Data exfiltration via beacon endpoint abuse

If the host page is XSS-compromised, the embed is downstream of that. The framework's record will faithfully capture whatever the host page tells it to record. The embed records exactly what the host page tells it to record. This is documented as a known limitation, not a vulnerability — the embed is a downstream sink.

For hardened deployments, a payload-field allowlist per machine can be configured in /portal/embed.

1.5 Timing side channel via heartbeat

Heartbeat cadence (5077 ms ≈ 6.5 Zeqonds) is fixed and identical across all installs. Phase carried in the payload is computed from Date.now() % 0.777 — no high-precision timer. No fingerprinting via beat jitter beyond ~10 ms accuracy.

1.6 postMessage-bridge abuse on the iframe form

The iframe-mode listener only relays whitelisted method names (event, state, file, identify, ping, connection). Any other zsm: key is dropped. The iframe never echoes a value back — there's no postMessage reply, so the parent cannot use the iframe as a confused-deputy oracle.

1.7 Clickjacking the iframe

The iframe page is served with Content-Security-Policy: frame-ancestors * (it's meant to be framed) but with display:none; width:0; height:0 recommended in the snippet — there is no UI to click. Even if a malicious host frames it visibly, there is no clickable surface.

1.8 CSP-strict host pages refuse to load embed

The script form requires script-src https://zeqapi.com in the host CSP. The iframe form requires frame-src https://zeqapi.com. The pixel form requires img-src https://zeqapi.com. All three documented in /portal/embed.

1.9 Supply-chain compromise of zeq.dev/embed.js

  • embed.js served with Content-Security-Policy: default-src 'none'; connect-src 'self'. The runtime can call fetch and sendBeacon, nothing else.
  • Subresource Integrity: /portal/embed includes a copy-paste variant with integrity="sha384-…" and crossorigin="anonymous".
  • Build pipeline pins embed.js SHA-256 in apps/zeq-dev/public/embed.lock.json. CI fails if served bytes don't match.

2. State-channel HTML hosting — threats and mitigations

The hosting tier lets a machine owner upload arbitrary HTML/JS/CSS that gets served at https://zeqapi.com/s/:slug/* (or a subdomain — see the open question below). External data (user backends, third-party APIs) stays external. Framework stores only hashes/proofs/chain.

2.1 Hosted site runs JS that calls Zeq APIs as the machine owner

Hosted bytes are served from a separate origin — not zeq.dev. Production: https://*.zeqsite.app (or https://:slug.zeqsite.app if subdomain-per-slug is feasible). Cookies/CORS for zeq.dev are not in scope. The hosted page CANNOT read or send zeq.dev cookies.

2.2 Hosted site exfiltrates data via beacon to attacker's server

Strict CSP applied at serve time (response header is framework-controlled, NOT user-supplied):

Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self' data:;
connect-src 'self';
frame-ancestors 'none';
form-action 'self';
base-uri 'none';
sandbox allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox;

The site cannot fetch external origins. If the machine owner needs external API calls, they go through a proxy under their own state machine — /api/chain/:slug/proxy?url=... — auth-gated, rate-limited, with an owner-allowlisted URL pattern.

2.3 Hosted site frames itself or others to clickjack

frame-ancestors 'none' (above) plus X-Frame-Options: DENY. The hosted site cannot itself be framed. Per-slug exception requestable in /portal/host.

2.4 File upload of malicious binary served as application/javascript

MIME is forced server-side from a fixed map of file extensions. Owner cannot override. Unknown extensions get application/octet-stream + Content-Disposition: attachment.

2.5 Slug squatting / takeover

Slug ownership is bound to the spinning Zeq ID at create time and recorded on the entangled state. Slug recycling requires a 7-day TTL after explicit delete. The serve handler joins on state_machines by slug → owner_zid; if owner_zid was rotated, all hosted bytes 404 until re-claimed.

2.6 Hash collision / chain-tampering with hosted bytes

Every served byte stream is committed to chain as sha256(serve_bytes) || serve_path || zeqond_number. Free tier stores only the digest; paid tier stores bytes alongside (separate billing path). Reads include X-Zsm-Content-Sha256 response header so external verifiers can independently re-hash.

2.7 Cross-slug pollution via shared static assets

Each slug gets an isolated namespace /s/:slug/; assets cannot reference /s/other-slug/. Server-side path normalization rejects .., absolute paths, and any path that resolves above the machine root.

2.8 DDoS via cheap-to-host content

Per-slug bandwidth quota: free tier 1 GB/month egress, hard cutoff. Per-slug request quota: 10K serves/day free tier. Quotas live in tallyEconomy.ts alongside compute credits.

2.9 SEO / phishing piggyback on zeqsite.app reputation

zeqsite.app ships a robots.txt and meta-robots default of noindex, nofollow for free tier. Owner can request indexing via /portal/host/seo after domain-verification check. Suspected-phishing reports go to an internal review queue; admin can disable a machine (404 + chain row of disable).


3. Where embed and host meet

A common pattern: a machine owner hosts an HTML page at /s/:slug/landing AND drops embed.js on it. The hosted page is on zeqsite.app; the embed beacons to zeq.dev/api/embed/:slug/ingest. Cross-origin from page → embed endpoint is enabled by:

  • Access-Control-Allow-Origin: * (the public key already gates abuse).
  • Access-Control-Allow-Methods: POST, OPTIONS.
  • Access-Control-Allow-Headers: Content-Type, X-Zsm-Key.
  • No Access-Control-Allow-Credentials — embed is cookie-less by design.

This is the only route on zeq.dev that allows wildcard CORS. Every other route stays origin-locked.


4. Audit hooks

Every numbered threat above maps to a unit test in shared/api-core/src/__tests__/:

ThreatTest file (proposed)
1.1 frozen APIembedFrozen.test.ts (jsdom)
1.2 rate limitembedRateLimit.test.ts
1.3 schemaembedSchema.test.ts
1.6 postMessage whitelistembedPostMessage.test.ts
1.9 SRI / lockextends chrome-ext-paths.test.ts to cover embed.lock.json
2.1 origin separationhostOrigin.test.ts
2.2 CSP headerhostCSP.test.ts
2.4 MIME forcinghostMime.test.ts
2.5 slug ownershiphostOwnership.test.ts
2.6 hash commitmenthostHash.test.ts
2.7 path traversalhostTraversal.test.ts
2.8 quotashostQuotas.test.ts

5. Open questions for project-lead sign-off

  1. Hosted-content originzeqsite.app (separate domain, requires DNS) vs. *.zeqsite.zeq.dev (single cert wildcard, no extra domain). Recommendation: separate domain to fully isolate cookie scope.
  2. Paid hosting tier byte storage — store in S3-compatible bucket or in Postgres bytea? Recommendation: bucket; chain stores only hash + bucket pointer.
  3. Embed key rotation cadence — manual only, or auto-rotate every N days? Recommendation: manual; auto-rotate is too aggressive for snippets pasted across many sites.
  4. Embed pixel/IoT auth — public key in querystring is logged in CDN access logs. Acceptable risk for pub_ keys (read-only-public anyway). Confirm with owner.