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:
| Command | Purpose |
|---|---|
sitebin list | List 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 reports | List 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 caddyfile | Print the generated Caddyfile. |
sitebin healthcheck | Probe 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, sodocker psshows the instance's health out of the box. - Freeze:
SITEBIN_READONLY=truedisables 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 thejson-filedriver to three 50 MB files; a journald or Loki shipper needs its own retention. - Abuse reports keep only the reporter's network
(
/24for IPv4,/48for 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 — answers410with a "Site suspended" page, before the expiry and view-password checks. A container site's services are stopped. Form submissions answer410. 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}, MCPget_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
409naming 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.
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
blockrule 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 tometa.jsonbefore the file is renamed into place, so a held upload is never served, not even for a moment. The caller gets403"This site is locked by the operator: held for review — …". A held creation still gets its tier's lifetime and its owner, and then answers403without an edit password: the site exists only as evidence. - Flagged. Every other hit — a
flagrule, or ablockrule 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 withdecoded: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 emptyruleslist switches the scanner off.- A rule matches a file when every
allpattern occurs in it and, ifanyis not empty, at least oneanypattern 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 prefixedre:— content is lowercased before it is matched, so write a regular expression in lowercase too;filesoptionally limits a rule to extensions (without the dot). Severity isblock,flagoroff. - 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.) and antelegram.org/bot actionoflock,alertoroff. A built-in destination is overridden by its id, like a rule.
The built-in rules, by id:
| Rule | Severity | Matches |
|---|---|---|
telegram-bot-api | block | api. |
telegram-sender | block | sendto |
chat-webhook | block | Discord and Slack webhook URLs (discord., hooks., …) |
botcheck-password | block | id="botcheck" with a password field |
clearbit-logo-password | block | logo.clearbit.com with a password field |
dns-lookup-password | block | DNS-over-HTTPS lookups (dns.google/resolve, cloudflare-dns.com/dns-query) with a password field |
ip-lookup-password | block | IP lookups (api., api64., ip-api., ipapi., ipinfo., api.) with a password field |
hash-email-password | block | atob( and location.hash with a password field — the victim's address read from the link (markup only) |
brand-login-title | block | a <title> naming Microsoft, Office 365, Outlook, OneDrive, SharePoint, DocuSign, Adobe, WeTransfer, "Account Verification" or "Verify your account", with a password field |
brand-login-text | block | "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-text | block | lure sentences — "sign in to your Microsoft account", "to view the shared document", "your mailbox is full", … — with a password field |
wallet-deeplink | block | payment-app deep links: tez:, phonepe:, paytmmp:, gpay: |
upi-deeplink | flag | upi: — legitimate merchants use it too |
ipfs-script | flag | a <script src> on an IPFS gateway (ipfs.io, cloudflare-ipfs.com, dweb.link) |
obfuscation-eval-atob | flag | eval( |
obfuscation-charcode | flag | String.fromCharCode( with 40 or more numbers |
obfuscation-unescape | flag | unescape(" 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:
| Id | Destination |
|---|---|
telegram-bot | api. |
discord-webhook · discordapp-webhook | discord. |
slack-hook | hooks. |
ipify · ipapi-co · ip-api · ipinfo · db-ip | api. |
google-script | script. |
formspree · getform · formsubmit · submit-form · emailjs | formspree. |
webhook-site · pipedream | webhook. |
Before a report can lock anything:
- the report's document URL, when it has one, must belong to the same site as the report's host;
- at most 6 checks per hour per site and destination, 2 at a time;
- 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,
/reporton 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 andsecurity.txtall link to it. - The API,
POST /api/reportwithtarget,reason,detailsand an optionalcontact— 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:
| Field | Meaning |
|---|---|
t, last, n | when; for a merged burst, the latest time and the count |
action | site 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 |
surface | where it came in: ui, api, mcp, upload-token, webdav, ftp, dashboard, oidc, local |
auth | the credential: password, token, oauth, session, upload-token, none |
account | the acting account; empty for anonymous and edit-password writes |
ip | the client address: the last X-Forwarded-For entry, the one Caddy appended — never one the client chose; the connection's peer for FTP |
ua | the User-Agent, control characters stripped, at most 200 bytes |
site, files, detail | the 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'sfiles/, so it is never served, listed, counted against a quota, zipped into a download or visible to a container. On Enterprise,accounts/<id>/provenance.jsonlholds 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 withfiles: 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.
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.
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=offswitches 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=dockerhands Sitebin the Docker socket, which is root on the host. Run container sites on a server dedicated to Sitebin, prefer a socket proxy, and considerSITEBIN_CONTAINERS_RUNTIME=runsc. Services with egress can reach whatever the host's bridge can, private ranges included — block those in theDOCKER-USERchain. 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 carryincludeSubDomains; a customer's custom domain does not, because that zone is theirs.
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:
| Variable | Default |
|---|---|
SITEBIN_CSP_SCRIPT_HOSTS | https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com https://cdn.tailwindcss.com https://code.jquery.com |
SITEBIN_CSP_STYLE_HOSTS | https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com |
SITEBIN_CSP_FONT_HOSTS | https://fonts.gstatic.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://unpkg.com |
SITEBIN_CSP_IMG_HOSTS | none |
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.
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,noindexpage: 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 owndefault-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, andwwwcan 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>
| Variable | Default | Purpose |
|---|---|---|
SITEBIN_ABUSE_CONTACT | — | The public abuse mailbox, on the info page and in security.txt. Unset: omitted everywhere. |
SITEBIN_ABUSE_REPORT_URL | https://<base>/report | The report page they link to; none omits it. |
SITEBIN_HOME_URL | the base URL | Your 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).