Operations & security

Day-two life with a Sitebin instance: the operator CLI, backups, health, and what you should know before hosting strangers' content on the public internet.

Operator commands

All operator commands run inside the container, e.g. docker exec sitebin sitebin <cmd>. Where a command takes <id>, that is a site's view id, its edit id or one of its domains:

CommandPurpose
sitebin listList all sites (id, size, files, mode, created, from, lock, owner/domains). FROM is the address the site was created from; a locked site reads LOCKED in the LOCK column, and a line below it says when the lock retention purges it, or that an evidence hold keeps it.
sitebin reportsList filed abuse reports — from the report page and the API alike. See Abuse reports.
sitebin scan <id> [--lock]
sitebin scan --all [--lock]
Run the upload scanner over sites already on disk. Without --lock it only reports and writes nothing; with it, it records the findings and holds the sites the upload scanner would hold (never a trusted one).
sitebin provenance <id>
sitebin provenance <ip|cidr>
Print a site's provenance log — or every site whose log names that address or range, with the matching entries.
sitebin lock <id> [reason…]Lock a site: served to nobody, frozen for its owner, kept past its expiry as evidence — until the lock retention runs out. The reason is shown to the owner. Re-locking a locked site keeps its lock date. See Locking a site.
sitebin unlock <id>Lift a lock, and any evidence hold with it; the site is served again and its expiry applies again.
sitebin hold <id>Place an evidence hold on a locked site: a case is still open, so the cleanup sweep keeps it past the lock retention. See Retention.
sitebin unhold <id>Release the evidence hold; a lock older than the retention is purged at the next sweep.
sitebin delete [--force] <id>Take down a site. A locked site is refused unless you add --force.
sitebin backup [file]Write a gzip tar of /data (stdout if no file).
sitebin restore <file>Restore /data from a backup.
sitebin caddyfilePrint the generated Caddyfile.
sitebin healthcheckProbe the internal health endpoint.

Backup

The /data volume is everything — sites, indexes, and certificates. Back up that path and you have backed up the whole instance. Alternatively, sitebin backup streams a snapshot:

docker exec sitebin sitebin backup - > sitebin-$(date +%F).tar.gz

Restore a snapshot with sitebin restore <file>.

Health, freeze, and logs

  • Health: the image ships a HEALTHCHECK, so docker ps shows the instance's health out of the box.
  • Freeze: SITEBIN_READONLY=true disables new-site creation — existing sites keep serving.
  • Logs: structured request + lifecycle logs on stdout (docker logs). Request lines carry the client IP, so retention is yours to bound: the example compose sets the json-file driver to three 50 MB files; a journald or Loki shipper needs its own retention.
  • Abuse reports keep only the reporter's network (/24 for IPv4, /48 for IPv6), never the full address — plus the contact address, when the reporter gave one — and are purged by the cleanup sweep after 14 days; at most 10,000 are kept, oldest first out.
  • Provenance logs are purged by the same sweep 90 days after their latest entry, except where a lock or a suspension holds them — up to the lock retention (below).
  • Locked sites are purged by the same sweep once their lock is older than SITEBIN_LOCK_RETENTION_DAYS (default 180), unless you placed an evidence hold (below).

Locking a site

A lock is the operator's evidence hold for abuse — phishing, scams, malware. Deleting such a site destroys what an abuse report to a hosting provider, a registrar or the police needs, and an expiry only hands it to the cleanup sweep a day later. A locked site is kept exactly as it is, served to nobody, and changed by nobody but you — for as long as the lock retention allows, or an evidence hold says a case is still open.

Lock from the instance register at /account/admin (Lock on the site's row, an optional reason, then Yes, lock) or from the CLI:

docker exec sitebin sitebin lock <view-id|edit-id|domain> phishing, reported 2026-09-28
docker exec sitebin sitebin unlock <view-id|edit-id|domain>
docker exec sitebin sitebin hold <view-id|edit-id|domain>     # a case is open: keep it past the retention
docker exec sitebin sitebin unhold <view-id|edit-id|domain>
docker exec sitebin sitebin delete --force <view-id|edit-id|domain>   # only a deliberate takedown

The reason is optional, at most 200 characters, and shown to the owner — keep private notes out of it. The CLI writes from its own process; the running server reads the lock on the next request, without a restart. What a lock does:

  • Not served, anywhere. Every address the site has — its subdomain, its custom domains, a /v/<id>/ path — answers 410 with a "Site suspended" page, before the expiry and view-password checks. A container site's services are stopped. Form submissions answer 410. CSP violation reports keep being counted: they are evidence.
  • Frozen for its owner. Every API route, every MCP tool, WebDAV, FTP and upload tokens answer 403 "This site is locked by the operator" with the reason — after authenticating the caller, so a stranger learns nothing. The owner can still read the site's settings (GET /api/sites/{edit-id}, MCP get_site, list_sites), which carry the lock but no file list; downloads, file content and folder listings are refused like writes. The dashboard shows the lock and hides rename, password rotation and delete. Locking also ends the site's cached password checks and revokes its open upload tokens.
  • Never deleted at expiry. The cleanup sweep leaves a locked site alone however far past its expiry, and a tier change does not restamp it. It is removed by your explicit delete — the register's two-step delete, which says the site is locked, or sitebin delete --force — or by the lock retention.
  • It holds the account too. An account that owns a locked site cannot be deleted — neither by its owner nor by a GDPR order from the SaaS Stack, which gets a 409 naming the sites — until the locked sites are gone: deleted by you once you have collected the evidence, or purged by the lock retention. The stack's retry then goes through.

In the register a locked row carries a Locked badge with the date, who locked it and the reason, a Locked filter and figure, and Unlock. A site locked by an account suspension also offers Keep, which turns it into your own lock, so lifting the suspension does not release it. Under the badge each locked row says when the lock retention purges the site, or that an evidence hold keeps it, with Hold or Release hold next to Unlock. Unlocking serves the site again at once and puts its expiry back in force: a date already past means the next sweep deletes it, so extend it first if the site should stay. Every action in the register is logged with the admin's id.

The runbook for a phishing or scam site: lock it (never delete it), suspend the owner in the SaaS Stack so the suspension carries into every surface, check the account's other sites, then report the infrastructure the page used — and place an evidence hold on the lock while that case is open. The community edition has the lock and the CLI too; the register and suspensions are Enterprise.

Retention and the evidence hold

A lock keeps a site as evidence for SITEBIN_LOCK_RETENTION_DAYS — default 180 days — from the moment it was locked. Then the cleanup sweep purges it completely: its files, its metadata and findings, its indexes and its provenance log, its containers first. Each purge is logged and arrives as one line in the alert digest. 0 keeps locked sites forever.

  • The clock runs from the first lock. Re-locking a locked site, Keep on a scanner's or a suspension's lock, or a suspension arriving on a site you locked never resets it — otherwise retention would never end. Only an unlock does; a lock placed after it starts a new clock.
  • An evidence hold stops it. While a case, investigation or proceeding is still open, place a hold on the lock — Hold in the register, or sitebin hold — and the site is kept past the retention until you release it (Release hold, sitebin unhold). A lock already older than the retention is purged at the next sweep after the release; both steps say so. Unlocking ends the hold with the lock; lifting a suspension does not — a suspension's lock with a hold on it becomes your own lock instead.
  • Provenance follows. A locked site's log goes with the site. A suspended account's log, or that of an account owning a locked site, keeps entries up to the retention instead of 90 days — all of them while one of its sites is held.
  • The owner sees none of it. The edit page and the dashboard show the lock, never whether a case is open or when it ends.

Abuse detection

Three mechanisms find abuse without waiting for a report, and one makes sure a report reaches a person. All of them are in the core: the community edition runs them too, but marks every site trusted, so it never locks on its own — it records, logs and mails, and you lock with sitebin lock.

The upload scanner

Every write — the drop page, the API, MCP, zip uploads, replace, upload tokens, WebDAV and FTP — is scanned for the signatures of phishing and scam kits before the file becomes visible. Text only: known binary extensions and files with a NUL in their first bytes are skipped, and at most the first 8 MiB of each file is read. A rule can lock only on an active file, one a browser renders or runs — .html .htm .xhtml .shtml .svg .js .mjs .cjs; the same hit in a .txt, .md, .json or .php is recorded as a flag. A rename (WebDAV MOVE, FTP RNTO) is scanned as its destination, so kit.txt renamed to kit.html is caught.

  • Held. A block rule on a site that is unlocked, untrusted (an anonymous drop, or a tier without "trusted"), not a container site and not the operator's own locks it — "by the scanner", with the rule and file as the reason. The lock is written to meta.json before the file is renamed into place, so a held upload is never served, not even for a moment. The caller gets 403 "This site is locked by the operator: held for review — …". A held creation still gets its tier's lifetime and its owner, and then answers 403 without an edit password: the site exists only as evidence.
  • Flagged. Every other hit — a flag rule, or a block rule on a trusted, container or operator site — is recorded and alerted, and nothing is locked.
  • Findings (rule, file, excerpt, SHA-256, time, source) are kept in the site's meta.json, at most 20, newest first. An excerpt that starts with decoded: was found only once the file was decoded (see Rules are a file): the file hid it.
  • Released. Unlocking a site, or dismissing its findings in the register, moves the flagged files' fingerprints into the site's reviewed set: exactly those bytes are never recorded or held again; anything new is scanned as usual.

Known limits, accepted: a kit split so that no single file matches a combined rule is caught only by its single-pattern rules and the tripwire below; string-splitting, base64 and text split by tags are not caught at all, and an encoding that decodes into another encoding is followed only one round.

Rules are a file

The built-in rules are compiled in. An instance adds to or overrides them with abuse-rules.json in its data directory (/data/abuse-rules.json in the container), so a new kit is one line, not a release. The file is re-read at most every 10 seconds when its modification time changes; one that does not parse or validate is logged at ERROR and the last good rules stay in force; a deleted file means the defaults again.

{
  "defaults": "extend",
  "rules": [
    {"id": "new-kit-2026-10", "severity": "block",
     "all": ["type=\"password\"", "re:sendto(telegram|discord)"],
     "description": "..."},
    {"id": "obfuscation-charcode", "severity": "off"}
  ],
  "exfil": [
    {"id": "formspree", "url": "formspree.io", "action": "alert"}
  ]
}
  • defaults: "extend" (the default) merges by id — an entry with a built-in id replaces it, and "severity": "off" disables it; "replace" drops every built-in, so an empty rules list switches the scanner off.
  • A rule matches a file when every all pattern occurs in it and, if any is not empty, at least one any pattern does — that is how "a password field and a way to send it away" is written, so an ordinary login form does not trip it. Patterns are case-insensitive substrings, or RE2 regular expressions when prefixed re: — content is lowercased before it is matched, so write a regular expression in lowercase too; files optionally limits a rule to extensions (without the dot). Severity is block, flag or off.
  • Every file is matched as written and decoded: HTML character references (except those for < and >, which never become markup), JavaScript escapes (\x3a, \u003a) and percent-encoding (%3a) — one character encoded no longer hides a link from a rule. A pattern counts when either view shows it.
  • An exfil entry is a tripwire destination: an id, a host with an optional path prefix (api.telegram.org/bot) and an action of lock, alert or off. A built-in destination is overridden by its id, like a rule.

The built-in rules, by id:

RuleSeverityMatches
telegram-bot-apiblockapi.telegram.org/bot
telegram-senderblocksendtotelegram
chat-webhookblockDiscord and Slack webhook URLs (discord.com/api/webhooks, hooks.slack.com/services, …)
botcheck-passwordblockid="botcheck" with a password field
clearbit-logo-passwordblocklogo.clearbit.com with a password field
dns-lookup-passwordblockDNS-over-HTTPS lookups (dns.google/resolve, cloudflare-dns.com/dns-query) with a password field
ip-lookup-passwordblockIP lookups (api.ipify.org, api64.ipify.org, ip-api.com, ipapi.co, ipinfo.io, api.db-ip.com) with a password field
hash-email-passwordblockatob( and location.hash with a password field — the victim's address read from the link (markup only)
brand-login-titleblocka <title> naming Microsoft, Office 365, Outlook, OneDrive, SharePoint, DocuSign, Adobe, WeTransfer, "Account Verification" or "Verify your account", with a password field
brand-login-textblock"sign in" or "log in" next to a brand whose login it imitates (Office 365, OneDrive, SharePoint, DocuSign, WeTransfer, Outlook Web, Microsoft account, Adobe Sign, …), with a password field
lure-login-textblocklure sentences — "sign in to your Microsoft account", "to view the shared document", "your mailbox is full", … — with a password field
wallet-deeplinkblockpayment-app deep links: tez://upi, phonepe://, paytmmp://, gpay://
upi-deeplinkflagupi://pay — legitimate merchants use it too
ipfs-scriptflaga <script src> on an IPFS gateway (ipfs.io, cloudflare-ipfs.com, dweb.link)
obfuscation-eval-atobflageval(atob(
obfuscation-charcodeflagString.fromCharCode( with 40 or more numbers
obfuscation-unescapeflagunescape(" with 60 or more %xx

The CSP-report tripwire

Untrusted sites report every blocked request to /_sitebin/csp-report (see Content security headers), and those reports keep being counted as before. When a report's blocked URL matches an exfil destination, Sitebin checks it before acting. The built-in destinations, all with action lock:

IdDestination
telegram-botapi.telegram.org/bot
discord-webhook · discordapp-webhookdiscord.com/api/webhooks · discordapp.com/api/webhooks
slack-hookhooks.slack.com
ipify · ipapi-co · ip-api · ipinfo · db-ipapi.ipify.org · ipapi.co · ip-api.com · ipinfo.io · api.db-ip.com
google-scriptscript.google.com/macros
formspree · getform · formsubmit · submit-form · emailjsformspree.io · getform.io · formsubmit.co · submit-form.com · api.emailjs.com
webhook-site · pipedreamwebhook.site · pipedream.net

Before a report can lock anything:

  1. the report's document URL, when it has one, must belong to the same site as the report's host;
  2. at most 6 checks per hour per site and destination, 2 at a time;
  3. the site's own files are searched for the destination (at most 5,000 files and 64 MiB). A report is unauthenticated — anyone can send one — so only content that actually references the destination can be locked by it.

Referencing files become findings (rule csp:<id>, the blocked URL as the excerpt), and the site is held when the action is lock and the site is lockable, exactly as for the scanner — a trusted site is alerted, never locked. When no file references the destination, the event is logged and alerted as unverified and nothing is recorded on the site.

Abuse reports

Anyone can report a site, without an account:

  • The report page, /report on the base domain: the address (prefilled from ?url=), a reason — phishing, malware, fraud or scam, spam, illegal content, copyright, other — details, and an optional contact address. It runs no JavaScript: spam is kept out by a honeypot field and a signed form ticket that must be between 3 seconds and 2 hours old. The "Site suspended" page, the landing page's footer, the view domain's info page and security.txt all link to it.
  • The API, POST /api/report with target, reason, details and an optional contact — see the API reference.

Both share the limits: 20 reports an hour per address, 200 an hour instance-wide, and one report per target and source network a day — a repeat is acknowledged but not stored, so one script cannot bury a competitor in reports. Targets resolve to a site from its address, a custom domain, a view id or a /v/<id>/ path. Every stored report is mailed to you (below), listed by sitebin reports and shown in the register, and purged after 14 days.

Alerts

Every hold, flag, tripwire hit and stored report is mailed as plain text — Microsoft 365 quarantines HTML mail from a server like this — to SITEBIN_ABUSE_ALERTS_TO (comma-separated; default: the SITEBIN_ADMIN_ACCOUNTS addresses), through the forms mailer (SITEBIN_FORMS_SMTP_*, see Configuration). Without that mailer alerts are only logged, and a startup warning says so. Subjects start with [sitebin abuse]. Each alert names the site, its owner (email and id), its lock state, the rule, file and excerpt, and links to the site's row in the register. Every URL taken from content or a report is defanged (hxxps://api[.]telegram[.]org/…) so the alert itself does not read as phishing to a mail filter.

A flood cannot mail-bomb you: at most one mail per site or report target an hour, and ten immediate mails an hour in all; everything beyond goes into a digest sent an hour after the first event it holds (200 lines at most, then a count). Routine events go only to the digest and spend none of that budget: an unverified CSP report, and each locked site the lock retention purged.

In the instance register

  • A Reports tab (/account/admin/reports), newest first: when, reason, target, details, contact, source network, and the resolved site with its owner and lock state. Lock site locks it in one step, with the reason "abuse report: <reason>".
  • The Flagged filter shows sites with scanner findings or CSP-blocked requests; a Scanner figure counts sites with findings.
  • A row with findings lists the first three (rule · file · excerpt) and offers Dismiss, which marks them reviewed without unlocking anything.
  • A scanner's lock reads "locked … by the scanner" and, like a suspension's, offers Keep to make it your own — it keeps the lock's date, so the lock retention still runs from the scanner's hold.

Provenance

For every site Sitebin records who made it and every change to it, from where — so that "was this the same person?" has an answer when an abuser comes back under a new account. Each entry holds:

FieldMeaning
t, last, nwhen; for a merged burst, the latest time and the count
actionsite log: create, upload, replace, delete-file, mkdir, move, copy, settings, domain-add, domain-remove, form-add, form-update, form-remove, form-resend, container, open-upload, rename, rotate-password; account log: signup, signin, token-mint, site-create, site-delete
surfacewhere it came in: ui, api, mcp, upload-token, webdav, ftp, dashboard, oidc, local
auththe credential: password, token, oauth, session, upload-token, none
accountthe acting account; empty for anonymous and edit-password writes
ipthe client address: the last X-Forwarded-For entry, the one Caddy appended — never one the client chose; the connection's peer for FTP
uathe User-Agent, control characters stripped, at most 200 bytes
site, files, detailthe site acted on (account log); what changed — a file count and the first paths, which settings, a domain, a token's name
  • Where. sites/<view-id>/provenance.jsonl — outside the site's files/, so it is never served, listed, counted against a quota, zipped into a download or visible to a container. On Enterprise, accounts/<id>/provenance.jsonl holds the account's sign-up, sign-ins, every API token minted, and a copy of its site creations and deletions, so a site its owner deleted still leaves "created from address X" behind.
  • Bounded. 100 entries per file, oldest out first — except the origin (create, signup), which is kept. The same action from the same account, address and client within 10 minutes is merged into one line: a 500-file WebDAV sync is one entry with files: 500. Reads are not recorded, and your own register actions are logged with your admin id instead.
  • 90 days. The cleanup sweep purges entries 90 days after their latest time. A locked site's log is not purged — it is part of what the lock holds — and goes with the site when the lock retention purges it. The log of a suspended account, or of one that owns a locked site, keeps entries up to the lock retention (default 180 days) instead — all of them while one of its sites carries an evidence hold, or with the retention off. Deleting a site deletes its log; deleting an account deletes its log and its sites. Backups keep purged entries for as long as you keep backups.
  • Exported. The GDPR export the SaaS Stack orders carries the account's own log and, from each site it owns, the entries that account made — see SaaS-Stack integration.

In the register each row shows the creator's address and surface (client on hover) and the latest write; Trail opens the site's whole log and the owner's account log. A token minted within ten minutes of sign-up is marked with the delay — scripted accounts do exactly that. Searching for an IP address or a CIDR range (203.0.113.7, 203.0.113.0/24, 2001:db8::/48) lists every site whose log names it and, above them, every account seen there.

Provenance is personal data of your users. Say in your privacy policy what you record and for how long, and on which legal basis — the hosted service's privacy policy is one way to put it.

Availability & failover

Sitebin is a single-writer system: writes are serialized by in-process locks, so exactly one instance may run against a given /data at any time.

Never run two containers on the same, shared, or bidirectionally synced volume — multi-step operations (uploads with quota enforcement, index updates, replace-all) can interleave and corrupt state. Active–active is not supported.

Everything else about the design makes failover easy: the container is disposable and /data is the entire instance — sites, indexes, accounts, certificates, and the .secret that keeps sessions valid across a move.

Baseline: restore to a fresh server

With streaming backups and a low DNS TTL, this alone gives minutes-level recovery — and it's the plan every deployment should have and test:

# continuously (cron) on the primary. pipefail, and a temporary name
# renamed into place, so a backup that fails never replaces the last good
# one. pipefail needs bash: put SHELL=/bin/bash at the top of the crontab.
set -o pipefail
docker exec sitebin sitebin backup - | ssh backup-host 'cat > sitebin-latest.tar.gz.part' \
  && ssh backup-host 'mv sitebin-latest.tar.gz.part sitebin-latest.tar.gz'

# disaster: on any fresh server with Docker
docker run -d --name sitebin -v sitebin-data:/data … sitebin:latest  # same env as before
cat sitebin-latest.tar.gz | docker exec -i sitebin sitebin restore /dev/stdin
docker restart sitebin
# point DNS (base domain, wildcard, custom domains) at the new server;
# certificates re-issue automatically if missing.

Keep the compose/env file in version control — server + compose file + backup is the complete instance.

Active–passive standby

When minutes of downtime are too many: replicate the volume block-level to a second server — DRBD (synchronous, RPO ≈ 0) or ZFS send/recv on a tight interval — with the container stopped on the standby. On failure, promote the replica, start the container, and move the floating IP (or flip low-TTL DNS). The one inviolable rule is the single-writer rule: make sure the old primary is down (fencing) before the standby starts.

Read replicas (one writer, many readers for view traffic) are architecturally feasible and on the enterprise roadmap, but not implemented today.

Security notes

  • User content is only served on random subdomains and custom domains — never on the main domain. Each site gets its own origin.
  • A custom domain is attached — indexed, served, issued a certificate — only once its DNS proves it belongs to the site: a TXT record with the site's token, or a CNAME at the site's own address. A pending claim reserves nothing. SITEBIN_DOMAIN_VERIFICATION=off switches the proof off, with a startup warning, for an instance whose every account holder you trust. See Custom domains.
  • Passwords are stored as Argon2id hashes; password attempts (API, gate, and WebDAV) are rate limited per IP and per site. The enterprise edition's local-account routes are rate limited too, with fixed limits: sign-in 10 attempts in a burst then 30/hour, per IP and per email address; signup 5/hour per IP; password-reset mail 3/hour per address. Passwords are 8 to 256 characters.
  • Uploads are sanitized against path traversal; symlinks in zips are rejected; per-site size/count quotas are enforced during streaming. No file surface — API, ZIP, WebDAV, FTP — follows a symlink out of a site's directory, because container sites put code Sitebin does not control in front of those directories.
  • SITEBIN_CONTAINERS=docker hands Sitebin the Docker socket, which is root on the host. Run container sites on a server dedicated to Sitebin, prefer a socket proxy, and consider SITEBIN_CONTAINERS_RUNTIME=runsc. Services with egress can reach whatever the host's bridge can, private ranges included — block those in the DOCKER-USER chain. Customer containers run as Sitebin's uid with no capabilities, a read-only root filesystem and fixed memory, CPU and process limits, and publish no port.
  • The authz/tls-check/health endpoints live on a separate listener that is never proxied publicly.
  • Every TLS origin sends Strict-Transport-Security (one year). The main domain and the view wildcard carry includeSubDomains; a customer's custom domain does not, because that zone is theirs.
Open, no-login file hosting attracts phishing and malware. As the operator you are responsible for what your instance serves: set up the abuse alerts, keep the lock command handy, consider tight SITEBIN_MAX_* limits and SITEBIN_MAX_EXPIRY_DAYS, and publish an abuse contact (SITEBIN_ABUSE_CONTACT, below) if the instance is exposed to strangers.

Content security headers

Sitebin serves HTML that strangers uploaded, so it sets response headers on every site it serves. There are two levels, and which one a site gets depends on whether the plan behind it is one you can hold accountable.

Every site, both editions — hygiene that breaks nothing:

X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(),
                    payment=(), usb=(), midi=()
Content-Security-Policy: object-src 'none'; base-uri 'self'

Untrusted content — anonymous drops, and sites on any tier not marked "trusted" — gets the exfiltration blocks and a host allowlist folded in:

Content-Security-Policy: object-src 'none'; base-uri 'self';
    form-action 'none'; connect-src 'self';
    frame-ancestors 'none'; frame-src 'none';
    img-src 'self' data: blob: <image hosts>;
    script-src 'self' 'unsafe-inline' 'unsafe-eval' <script hosts>;
    style-src 'self' 'unsafe-inline' <style hosts>;
    font-src 'self' data: <font hosts>;
    media-src 'self' data: blob:; worker-src 'self' blob:;
    manifest-src 'self';
    report-uri /_sitebin/csp-report; report-to csp
Referrer-Policy: no-referrer

The host lists are configurable, because which CDNs a policy trusts is a decision that needs revisiting without a release:

VariableDefault
SITEBIN_CSP_SCRIPT_HOSTShttps://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com https://cdn.tailwindcss.com https://code.jquery.com
SITEBIN_CSP_STYLE_HOSTShttps://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com
SITEBIN_CSP_FONT_HOSTShttps://fonts.gstatic.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com
SITEBIN_CSP_IMG_HOSTSnone

Values are comma- or space-separated host sources (https://cdn.example, https://*.example.com, with a port or a path); none empties a list. Anything else — a keyword, a quote, a semicolon — is refused at startup, because the value is written into the Caddyfile and into a header. The keywords are fixed. Changing a list is an environment change and a restart.

The aim is deliberate: stop the exfiltration, and keep arbitrary hosts' code off anonymous and free sites. What a phishing kit loses first is the ability to ship what it captures: form-action 'none' kills the credential POST, connect-src 'self' kills fetch, XHR, sendBeacon and WebSockets, and img-src kills the image beacon (new Image().src = "https://…/?p=" + password). Those are separate directives because none of them covers the others — omitting form-action would leave the oldest trick in the book wide open.

'unsafe-inline' and 'unsafe-eval' stay, because nearly every static site inlines a script and many frameworks evaluate one; what the policy decides is which hosts a page may pull code, styles and fonts from. The allowlist is not a wall — jsDelivr and unpkg serve any npm package and any GitHub repository — but it keeps IPFS gateways, kit hosts, trackers and third-party widgets off, and every attempt lands in a CSP report the tripwire reads.

What it costs a legitimate untrusted site: images from other hosts (a hotlinked picture, a README badge in the viewer), scripts from any other CDN, fonts from any other host, and embedded third-party media. Everything Sitebin itself serves on a site's origin — the viewer, its PDF worker and the /_sitebin/ assets — comes from 'self' and keeps working. The embed component does not work on an untrusted site: connect-src 'self' already refused its upload, and its script is now refused unless it is loaded from the site itself.

An uploaded page cannot loosen any of this. A <meta> policy of its own can only narrow what the header already set.

What this does not stop. Top-level navigation still leaks — a page can send a visitor, and whatever it puts in the URL, anywhere. CSP has no answer for that at all — the navigate-to directive was dropped from the standard and is implemented nowhere. Closing it would mean sandbox, which also breaks every external link. Code hosted on an allowlisted CDN still runs. Treat these headers as raising the cost of the common kit and making the attempt visible, not as a guarantee.

Trusted tiers

A tier in tiers.json may set "trusted": true. Sites owned on it skip the strict layer — useful when the plan's holders are identifiable and their apps legitimately call an API on another domain. An anonymous site never qualifies, whatever its tier says. The community edition registers no account extension, so every site there is trusted and the strict layer never applies. A trusted site is also never locked automatically: the scanner and the tripwire flag and alert it, and the decision to lock is yours.

The decision is stored per site as a marker file, and the matcher keys on its absence: a site whose marker is missing is served more strictly, never less. A security control has to fail on the safe side.

Violation reports as an abuse signal

Untrusted sites report violations to /_sitebin/csp-report. Sitebin counts them per site, keeps the distinct destinations that were blocked, and counts the distinct reporting networks (/24, /48) — capped, rate limited per source and flushed on a timer so a hostile page cannot turn the endpoint into a write amplifier. A site whose first visitor trips form-action against a foreign host is almost always phishing — the counts, destinations and network count show up in the instance register at /account/admin, with a filter, next to the delete button. A phishing page with real visitors and one person's script look different there. A report that names a known collection point can lock the site on the spot — see the tripwire.

Separating user content from the app

Browsers draw most of their boundaries at the registrable domain, not the origin. If user sites live under the same registrable domain as the dashboard, an uploaded page can write cookies upward onto the app, SameSite treats navigations between them as same-site, and a phishing takedown against one site can put the app's own domain at risk. Set SITEBIN_VIEW_DOMAIN to a separate registrable domain and list that domain in the Public Suffix List. The hosted service does exactly this: the app is on app.sitebin.io, user sites are on sitebin.app.

Who owns the view domain: the apex and security.txt

Someone who finds a phishing page on <id>.sitebin.app looks at sitebin.app next, and at its security.txt. When SITEBIN_VIEW_DOMAIN differs from the base domain (and sites are served on subdomains), the generated Caddyfile carries one more site block for <view> and www.<view>, with the same tls block as the wildcard. Those two hosts answer with nothing but:

  • / — a static, noindex page: this domain serves user-generated content for <base>, sites here are made by third parties, and how to report one (the report page and the abuse mailbox), with a link to your own site. No external resources, its own default-src 'none' policy.
  • /.well-known/security.txt (RFC 9116).
  • everything else — 404. The app's routes (/api, /account, /mcp, …) never answer on the view domain, and www can never be a site: view ids are exactly 26 characters.

The base domain serves the same security.txt:

Contact: mailto:<SITEBIN_ABUSE_CONTACT>
Contact: <report URL>
Expires: <now + 180 days, midnight UTC>
Preferred-Languages: en, de
Canonical: https://<base>/.well-known/security.txt
Canonical: https://<view>/.well-known/security.txt
Canonical: https://www.<view>/.well-known/security.txt
Policy: <report URL>
VariableDefaultPurpose
SITEBIN_ABUSE_CONTACT—The public abuse mailbox, on the info page and in security.txt. Unset: omitted everywhere.
SITEBIN_ABUSE_REPORT_URLhttps://<base>/reportThe report page they link to; none omits it.
SITEBIN_HOME_URLthe base URLYour main site, linked from the info page.

The "Site suspended" page a locked site answers with carries the same "report abuse" line. A site may also carry its own .well-known/ folder — security.txt included — like any other files: uploads accept it on every surface and it is served as is (sitebin.io itself, a Sitebin site, does exactly that).