1. Security Model

Every work in the collection is treated as untrusted code. The platform is built around three boundaries, and every control on this page belongs to one of them.

BOUNDARY 01

Origin

Submitted code runs on an opaque origin with no same-origin relationship to the platform, its cookies, or its storage.

BOUNDARY 02

Capability

A work declares what it needs. Undeclared capabilities are denied by browser policy, not left to review.

BOUNDARY 03

Provenance

The bytes that were reviewed are the bytes served. Artifacts are content-addressed and hash-published.

The design assumes the code may be hostile. It does not depend on a creator's reputation, and it does not depend on review catching every edge case.

2. Sandboxing

Browser works are sandboxed. This is the only mode in which the platform publishes executable content: a work that cannot run under the sandbox is not published. The sandbox is enforced by two independent mechanisms, and the work is served from a separate registrable domain, content-sandbox.com.

2.1 The iframe

Every work is embedded in a sandboxed iframe whose token list excludes allow-same-origin:

Without allow-same-origin, the framed document receives an opaque origin. It cannot read the parent DOM, cannot read or write platform cookies, cannot reach localStorage, sessionStorage, or IndexedDB, and cannot issue same-origin authenticated requests. No user gesture changes this — the restriction is a property of the document's origin, not a permission prompt.

The path mirrors the site: {creator_slug}/{work_slug}/. The wrapper page at tinyfiles.tv/{creator_slug}/{work_slug}/ embeds the play page at content-sandbox.com/{creator_slug}/{work_slug}/. Nginx serves index.html from the trailing slash.

2.2 The response header

The work response carries a CSP sandbox directive with the same token set, so the restriction applies when the work URL is opened directly, not only when it is embedded. The sandbox travels with the document.

Content-Security-Policy: sandbox allow-scripts allow-forms allow-modals allow-downloads; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; media-src data: blob:; connect-src 'none'; frame-ancestors https://tinyfiles.tv X-Content-Type-Options: nosniff

Note: the header above is the complete header for every work. There is no per-work connect-src exception. Save API calls go through the parent page over postMessage, not from the work directly — see §3. Works never receive network privileges, and there is no declared capability that grants them.

2.3 The separate content domain

Works are served from content-sandbox.com, a domain that is registrably separate from tinyfiles.tv. It has to be a registrable domain of its own, not a subdomain of tinyfiles.tv, because a subdomain shares the registrable domain and therefore shares the browser's site boundary with the application.

A separate registrable domain is a different site in the browser's model: even without the iframe, a work has no same-origin path to platform cookies or storage, first-party cookies are never sent to it, and the browser's SameSite protections apply across the boundary. This is the same isolation pattern used for user-generated content at scale, and for the same reason: the content origin never shares an origin or a site with the application origin.

Why allow-same-origin is never present. The combination of allow-scripts and allow-same-origin on same-origin content defeats the sandbox, because the framed page can remove the sandbox attribute and restore its real origin. The platform therefore omits it by rule. The token list in §2.1 is the complete list; there are no per-work exceptions.

2.4 What the sandbox guarantees

The sandbox bounds blast radius; it does not make code safe. It does not stop a work from drawing, playing audio, or doing the thing it was built to do. It stops a work from acting on anything outside its own frame. That is the guarantee, and it holds regardless of how a work behaves inside its frame.

3. Persistence and the Save API

A sandboxed work cannot call localStorage, sessionStorage, or IndexedDB. That is intentional, and it is also why persistence works the way it does: save state lives either entirely in the player's hands (offline) or on the server (online), keyed by a sixteen-character code that works on any device, in any browser.

The save system is a sliding scale of three positions. One code format, one wrapper API, three levels of what the server sees and what the player holds.

Position 1 — Offline

The save is the armored block. The player encrypts in the browser, copies the block out, pastes it back in to load. Nothing reaches the server. No code is issued, no row is written.

Position 2 — Online, encrypted

The player holds a code and optionally an exported block. The server holds ciphertext, keyed by sha256(slug + ':' + code). The server never sees the plaintext or the code; it only ever holds opaque bytes and an opaque hash. This is the default for most works.

Position 3 — Online, plaintext

The player holds a code, plus an optional four-letter word. The server holds the state as JSON, keyed by the same hash function, plus a bcrypt hash of the word if one is set. The server can read the save; the word gates access to it. This is the trade for not holding a backup block.

The code is sixteen characters (xxxx-xxxx-xxxx-xxxx, alphabet excludes I, L, O, 0, 1 for readability). The word is four letters, a–z. Both are held by the player; neither is stored by the server in recoverable form.

How it reaches the API

The work calls a small documented wrapper (TF.save, TF.load) which sends a postMessage to the parent page. The parent makes the fetch. The work never holds an API key, never receives a connect-src grant, and never talks to the API directly.

// inside the sandboxed work const { code } = await TF.save(state); // -> { code: 'AB3N-7KM2-9XQP-4RZT' } const data = await TF.load('AB3N-7KM2-9XQP-4RZT'); // -> state

Persistence is optional. A work that stores nothing and calls no API is a valid submission. A work that needs state declares save_api in its manifest:

{ "id": "unravel", "capabilities": { "save_api": true, "pointer_lock": false, "fullscreen": false, "audio": true } }

save_api: true tells the wrapper page to wire up the parent-side postMessage handler. It does not grant the work network access. The work's CSP is unchanged: connect-src 'none'. The parent is the only caller of the save API.

Because the parent is the only caller, the save API has a single rate-limit point (nginx, keyed on the TCP peer) and a single audit point. A work that abuses the API is visible in one place.

What the server sees

No save endpoint reads an IP address. The save_codes table has no IP column, no account column, and no session column. There is no key that joins a save to a request log entry. Rate limiting happens in nginx at the proxy layer, in a separate layer with separate storage, keyed on $binary_remote_addr. The two systems cannot be joined because they share nothing.

4. Python Works

A Python work is never a Python file alone. It is an HTML + Python pair: the HTML is the submission — subject to every browser sandbox rule in §2 — and the Python is a backend function that the HTML calls over a documented interface. A Python-only submission is not accepted, because a Python-only submission has no UI and no reason to be on this site.

HTML — the submission

Runs in the browser sandbox from §2. Renders the UI, handles input, calls the backend. If the backend is unavailable, the HTML still loads and degrades gracefully.

→

Python — the function

Takes JSON, returns JSON. No UI, no server, no port. Called by the HTML, executed in an isolated container, destroyed after the response.

The pair is belt-and-suspenders by construction. The HTML runs in the browser sandbox, on the isolated content domain, with no network egress beyond the declared capabilities. The Python runs in a separate container on a separate execution path, with no network, no persistent filesystem, and no host access. Neither half can reach the platform, and neither can reach the other except through the documented interface. If one boundary fails, the other holds.

4.1 The Python container

Each invocation runs in a fresh container. The container's properties are the security boundary, and they are enforced at the kernel and orchestration level, not by code review:

docker run \ --net=none \ --read-only \ --cap-drop=ALL \ --security-opt=no-new-privileges \ --security-opt=seccomp=/etc/tinyfiles/python-seccomp.json \ --user 65534:65534 \ --memory=128m --memory-swap=128m \ --cpus=0.5 \ --pids-limit=32 \ --tmpfs /tmp:size=1m,noexec,nosuid,nodev \ --rm \ tinyfiles/python-runner:latest

The execution timeout is 3 seconds, enforced by the runner, not the container. The container is spawned per call, destroyed after the response, and never reused. There is no idle container to exploit, no warm process to pivot from, and no state to carry forward.

What the container cannot do, in plain terms:

  • Reach the network. No network namespace. No loopback, no DNS, no packets.
  • Write to disk. Read-only root. The only writable path is a 1 MB tmpfs at /tmp, mounted noexec.
  • Reach the host. No Docker socket. No host bind mounts. No /proc/1/cgroup visibility.
  • Escape the syscall allowlist. Anything not explicitly permitted by seccomp is killed by the kernel.
  • Escalate privileges. Non-root UID 65534. No new privileges. All capabilities dropped.
  • Exhaust resources. 128 MB memory, 0.5 CPU, 32 processes, 3-second timeout. Exceed any of them and the container dies.

4.2 bandit is the first gate

Every Python submission runs through bandit, the standard Python security linter, before a human sees it. bandit catches eval, exec, subprocess, pickle, hardcoded credentials, unsafe deserialization, and dozens of other patterns deterministically. Any finding at any severity is a rejection. No negotiation, no exceptions.

This is what makes the review tractable. A human reading a 16 KB Python file for subtle security bugs is doing hard, error-prone work. bandit reading the same file for known-dangerous patterns is doing easy, reliable work. We let bandit do the easy part, and the human does the editorial part — is this a good work, does it fit the collection, does it actually do what it claims.

pip install bandit bandit -r submission.py -lll -f json # Expected output for a clean submission: {"results": []}

Creators can run this themselves before submitting. If bandit reports anything, the submission will be rejected, so there's no reason to send it.

4.3 The adversarial test suite

The container is not trusted on the strength of its configuration. It is verified by a public adversarial test suite that attempts every known escape and reports whether each was blocked:

What the suite attempts

Filesystem reads and writes · network connects and DNS · subprocess and fork · environment reads · importlib and inspect · Docker socket probes · /proc reads · mmap and ptrace · fork bombs · CPU loops · memory exhaustion.

What the suite expects

Every attack blocked. If any attack succeeds against the container, the container is broken. The suite runs on every container deploy, and the result is either all-pass or all-fail.

The test suite is public. It's src/python-test-suite.py — anyone can read, run, and extend it.

If you find an attack that isn't in the suite, submit it. If it succeeds against the container, we fix the container or we close Python. Either way, the failure is public.

Adversarial by design. We don't ask you to trust the container. We ask you to try to break it. If you can, tell us. We'll fix it, credit you, and add your attack to the suite. If we can't fix it, Python closes.

4.4 The backstop

If the container fails a single test, Python closes. Not "Python gets reviewed more carefully." Not "Python gets a warning." Python closes, immediately, until the container is fixed. The test suite is the gate, and the gate is binary. If we can't run Python safely, we don't run Python.

This is stated on the Python page and repeated here because it's a commitment, not a disclaimer. The site is not obligated to accept Python. The moment the isolation stops holding, Python is out.

If a Python work's HTML is live and the Python backend goes down, the HTML still loads — it shows an error message where the Python response would have been. The work degrades gracefully because the HTML is the artifact and the Python is optional infrastructure. A submission that requires Python to render its UI is not accepted under the pair model; the UI is always HTML.

The full technical rules for Python submissions are on the Python page.

5. Intake & Publish Pipeline

A work reaches the browser in two stages. Intake decides whether a submission is accepted. Publish decides what the browser actually receives. Both are enforced by the platform, not left to review checklists.

5.1 Intake — form, staging, job queue

Submission is a form on tinyfiles.tv/submit.html. A creator uploads a payload (single HTML file or ZIP), a sidecar JSON, and optional cover images. The upload is staged in a directory outside the served tree; a jobs row of type intake is written; the request returns 202 { jobId }. Nothing about the submission is visible to the public until a human approves it.

A Python worker picks up the job and runs the intake pipeline:

  1. Static analysis. The artifact is inspected for network egress, dynamic eval, encoded payloads, obfuscation, parent-DOM access, storage access, form actions, and top-level navigation. Anything unexplained is a rejection, not a warning.
  2. Unzip safety. ZIP payloads are checked for path traversal, symlinks, and executables before extraction. Every file is content-addressed by SHA-256 at extract time.
  3. Wrapper generation. The wrapper page at tinyfiles.tv/{creator_slug}/{work_slug}/ is generated from the work template, with the iframe pointing at content-sandbox.com/{creator_slug}/{work_slug}/ and the capability manifest compiled into parent-side postMessage handlers.
  4. Row insert. A works row is written with status='pending_review'. A work_files row is written per extracted file, with role set. The job completes, and the work appears in the admin queue.

5.2 Publish — approval, trigger, regeneration

A moderator plays each pending work, reads the code, and approves or rejects. On approval, status becomes 'published' and work.published is emitted. A seeded trigger picks up the event and enqueues a catalog_regen job. The job regenerates data/works.js, sitemap.xml, and rss.xml from the works table. Nothing is hand-edited; nothing is rebuilt by hand.

Python submissions pass through two additional intake gates before the row is written. bandit (§4.2) and the container test suite (§4.3). Both must pass, or the submission is rejected and no works row is written. A Python file that fails bandit is rejected without a human reading it.

5.3 What the artifact is

The artifact is a content-addressed set of bytes: the work file(s), the sidecar, and the cover images, each with a SHA-256 recorded in the database. The wrapper page shows the hash next to the download, so a download can be verified against the published record without trusting the server. A later byte change produces a different hash and triggers a fresh intake review — the wrapper will not serve a payload whose hash doesn't match the published record.

The full intake rules — what's accepted, what's rejected, what the sidecar must contain — are on the Submission Criteria page.

6. Platform Hardening

The first-party origin serves no third-party JavaScript, no third-party fonts, no analytics SDK, and no tag manager. The only code that executes on tinyfiles.tv is the code in the repository.

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload Content-Security-Policy: default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-src https://content-sandbox.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self' X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=() Cross-Origin-Opener-Policy: same-origin Cross-Origin-Resource-Policy: same-origin

frame-src https://content-sandbox.com is the only origin the application is permitted to frame. There is no per-path exception; the entire content domain is the allowlist. The matching frame-ancestors https://tinyfiles.tv on the content domain (§2.2) makes the pair symmetric: only tinyfiles.tv can frame content-sandbox.com, and only content-sandbox.com can be framed by tinyfiles.tv.

TLS 1.2 is the floor and TLS 1.3 is preferred. First-party pages carry inline <style> and <script> blocks for per-page behavior — catalog filters, form validation, the crypto demos, the changelog renderer. Those blocks are scoped to 'unsafe-inline' in the current header, disclosed here rather than hidden. The target posture is to externalize the page scripts and hash-pin the theme bootstrap, at which point 'unsafe-inline' drops out of both directives.

7. Supply Chain and Download Integrity

There is no runtime package manager, no CDN dependency, and no build step that pulls code at install time. Every work is a single file or a ZIP containing the artifact and its sidecar. This removes the most common supply-chain path for a site of this size: a compromised dependency that nobody reviewed.

Every published work carries a SHA-256 in its database row and its wrapper page. The work page shows the hash next to the download, so a download can be verified without trusting the server:

sha256sum -c checksums.txt

First-party pages load no third-party subresources, so there is no Subresource Integrity surface on the main origin. Integrity is enforced at the artifact level through content addressing, which covers the case SRI does not: the file you keep after the page is closed.

8. Data Handling

Players do not have accounts and are not asked to create one. The first-party origin sets no cookies on read paths, runs no cross-site trackers, and carries no advertising code. Theme preference and any generated identity token live in sessionStorage by default and are discarded when the tab closes. Persistence is opt-in, explicit, and reversible.

Save data follows the sliding scale from §3. An offline save never reaches the server. An encrypted save is stored as ciphertext the server cannot read. A plaintext save is stored as JSON the server can read; the player chose this trade, and the work's page described it. No save record contains an IP, an account identifier, or any per-player correlation key beyond the code hash itself.

Members — creators, moderators, and raters — authenticate with a Bearer token that is a signed session payload, self-generated in the browser. No cookies, no email, no password. The server stores a public key and a claimed pseudonym; that's the entire record. Future additions — email for password recovery, billing for donations — will be added on top of this base, not baked into it. Player data will not gain email addresses or accounts as a side effect of the member system.

The full model is on the Privacy Policy.

9. Vulnerability Disclosure

If you find a security issue in the platform — the site, the API, the auth flow, the sandbox configuration, or the Python container — email the security contact. Do not post it publicly, and do not test against submitted works; those are third-party code and not ours to authorize testing against. The container test suite is the one exception: attempting to break the container is exactly what the suite is for, and attacks against the container are welcome.

Response commitment. A human reads every security report. We acknowledge within 72 hours and ship a fix within 14 days for anything high-severity. If more time is needed, we say why.

Safe harbor. Good-faith research is welcome. If you do not access other users' data, do not degrade the service, and give reasonable time to fix the issue, we will not pursue legal action and will work with you on the fix. These terms follow standard disclosure norms; they are a promise of good faith in both directions. Attempts to break the Python container are explicitly in scope and explicitly welcome — a working escape is a fix request, not a violation.

10. Threat Model

ThreatPrimary controlSecondary control
Malicious browser submissionOpaque-origin sandbox + capability CSPIntake pipeline
Malicious Python submissionKernel-enforced container isolationbandit gate + editorial review
Token theft via a workSeparate registrable domain + no allow-same-originNo credentials reachable from the content origin
Network egress from a browser workconnect-src 'none' for every workSave API goes through the parent page; work has no network path
Network egress from PythonNo network namespace (--net=none)Forbidden-import rule rejects the intent as well
Python container escapeRead-only + seccomp + no network + non-root + no-new-privilegesAdversarial test suite on every deploy; Python closes if any attack succeeds
Parent-frame interferenceOpaque origin + no allow-top-navigationreferrerpolicy="no-referrer"
Tampered artifactContent-addressed intakePublished SHA-256 in the work row and wrapper page
Cross-game save collisionsha256(slug + ':' + code) as the only lookup keySame code on two games produces two distinct hashes
Save data joined to a requestNo IP column in save_codes; rate limiting in nginxThe two systems share no key
Supply-chain dependencyNo runtime package managerFirst-party origin loads no third-party code

11. Control Status

Enforced means the control is a property of the serving path and holds for every published work. Policy means it is an operational commitment described on this page.

ControlStatusWhere it lives
Opaque-origin sandbox (iframe)EnforcedEvery work embed
CSP sandbox on work responsesEnforcedWork response headers
Separate registrable content domainEnforcedcontent-sandbox.com
Default-deny network from browser worksEnforcedconnect-src 'none', no per-work exception
Content-addressed artifactsEnforcedIntake pipeline
Save API reachable only via parent pageEnforcedParent-side postMessage handler
Save record cannot be joined to a requestEnforcedsave_codes has no IP column
Member session verification (server-side)EnforcedBearer token, ECDSA signature
Python: no network namespaceEnforced--net=none
Python: read-only root, non-root, no new privilegesEnforcedContainer flags
Python: seccomp syscall allowlistEnforcedKernel-level
Python: bandit on every submissionEnforced§4.2
Python: adversarial test suite on every deployEnforced§4.3
Python: HTML + Python pair only, no Python-onlyEnforced§4, python.html
Python closes if container test failsPolicy§4.4
72-hour acknowledgement, 14-day high-severity fixPolicy§9
Safe-harbor termsPolicy§9

12. See Also

For the full technical rules for Python submissions, see the Python page. For submission requirements that feed this pipeline, see the Submission Criteria. For the legal terms, see the Terms of Service (§3 covers acceptable use and security probing). For the privacy model, see the Privacy Policy.

Send a non-security report →