feat: security baseline + Tailscale self-host support#163
Open
ysalitrynskyi wants to merge 6 commits into
Open
feat: security baseline + Tailscale self-host support#163ysalitrynskyi wants to merge 6 commits into
ysalitrynskyi wants to merge 6 commits into
Conversation
ysalitrynskyi
marked this pull request as draft
July 17, 2026 01:29
ysalitrynskyi
marked this pull request as ready for review
July 18, 2026 13:51
Self-hosting a clipboard relay means the server holds every device's traffic, so the defaults have to be safe before anything else matters. - The H2 password no longer has a baked-in default, and startup fails without CC_SERVER_DB_PASSWORD. Detected by excluding jdbc:h2:mem: rather than by matching a jdbc:h2:file: prefix, so the other on-disk URL forms (jdbc:h2:~/x, ./x, /abs) cannot boot with an empty password — which, with CIPHER=AES, also meant the file was not meaningfully encrypted. - CC_ALLOWED_ORIGINS=* refuses to start. A wildcard origin is applied verbatim to both WebSocket endpoints, so any site a user visits could open a socket with their session cookie and read their clipboard. - X-Forwarded-For is honoured only from CC_TRUSTED_PROXY_CIDRS, which is empty by default. All header values are joined before the chain is walked right-to-left: getHeader() returns only the first, which is the client's own line when a proxy appends rather than replaces. Hops are canonicalised through InetAddress and rejected unless they are IP literals, so one host cannot occupy several brute-force buckets by varying spelling (203.0.113.050, ::::, 1.2.3.4:5678) or turn an inbound header into a DNS lookup. An unusable right-most hop falls back to the socket peer instead of walking further left into client-written entries. - CSP names the configured origins in connect-src; bare "ws: wss:" allowed a socket to any host. - The P2P announce branch requires a server-assigned peer id, so a session racing registration cannot announce itself as another device. - Container runs as uid 10001 with /database and /logs chowned. The resolver's configuration is now passed in rather than read from the environment inside the resolution logic, which is what finally made the trusted-proxy path testable: every previous test ran with the header ignored. Server tests 0 -> 26.
Content-Security-Policy is only meaningful if the pages can live without script-src 'unsafe-inline', so the inline handlers and script bodies moved out to login.js, signup.js, advance.js and main.js. No <script> body, no on*= attribute and no javascript: URL remains in any template or static page. The password prompts became a proper modal: the browser prompt() could not show what it was asking for, and the change flow now sends the current password so the server can verify it. style-src still needs 'unsafe-inline' for legacy inline styles; that is recorded as an open item rather than papered over.
Secrets (cookie, CSRF token, password, hashed password) move to the OS keyring
and are scrubbed from the DATA file. Only keys that actually reached the
keyring are removed: stripping unconditionally made the migration destructive,
because a machine with no backend stored nothing and deleted the only copy in
the same pass, ending a working login with a log warning.
Protocol v2 wraps each message in an envelope carrying a device id, counter and
timestamp, with that metadata bound inside the ciphertext so the outer copy
cannot be rewritten. Replay state is only touched after the AEAD verifies —
updating it first let an unauthenticated peer pin a victim's counter and stall
it. The replay window is per sender, so one sender's traffic cannot evict
another's history.
The envelope travels nested inside the transport frame's payload field. The
server models only {payload, type, metadata} and rejects unknown top-level
keys outright, so a flat envelope never reached the handler at all.
P2P signalling is signed with a per-device Ed25519 key and peers are pinned in
a TOFU trust store. A keyring read error now raises rather than minting a new
identity, which would present a new stranger on every launch and stop TOFU ever
converging.
Cleartext HTTP is allowed only for loopback, RFC1918, Tailscale CGNAT and
*.ts.net — suffix only, since ts.net itself is a public domain. The GCM nonce
is 12 bytes, the one length Apple's CryptoKit accepts, so iOS can decrypt;
decryption still accepts 16 so older peers keep working.
Desktop tests 0 -> 50.
Secrets move to react-native-keychain, and logout fails closed: if the wipe cannot be confirmed the session UI stays put rather than pretending it succeeded. Protocol v2 matches the desktop on both transports, including the nested transport frame. Fragment metadata from a peer is now validated before it indexes anything or sizes an allocation — id must be a UUID, index and totalFragments safe integers within range and under a 4096 cap. Without that, one frame could index the fragment map with "__proto__" and write Object.prototype: a destructuring default fires only on undefined, so an inherited allowLegacyV1 won and every later message on both transports was accepted as legacy v1. The map is now null-prototype, the flag is read as an own property, and both call sites pass it explicitly. READ_LOGS and SYSTEM_ALERT_WINDOW are gone, along with the logcat capture path. Capture is now the share sheet, a Quick Settings tile, a notification action and an in-app button. Those internal intents require a token from private storage, because an exported activity accepts any explicit intent and any installed app could otherwise make ClipCascade read the clipboard and put it on the wire — working around the Android 10+ background-read restriction the tile exists for. Cleartext is gated in networkPolicy.js for the private mesh. Only system CAs are trusted: adding user CAs to the base config would let any user- or MDM-installed CA intercept HTTPS to any host. The URL scheme is lowercased before parsing because React Native ships a regex URL polyfill that rejects HTTPS:// while jest's WHATWG parser accepts it, so the suite now runs against both parsers rather than one that never ships. versionCode 1 -> 320, which previously blocked every upgrade. Mobile tests 0 -> 55.
Compose files publish on CC_BIND_ADDRESS, defaulting to loopback, so a server is never exposed on every interface by accident. The bind lives in the base file rather than the Tailscale overlay because Compose merges ports by appending: an override would have added a second binding and left the first one live. CC_TRUSTED_PROXY_CIDRS is documented as narrowly as possible, ideally a single proxy host. The example previously suggested whole RFC1918 ranges, which on a LAN or Docker network is where the clients live — so any client could declare its own address and pick its own brute-force lockout key, inverting the guarantee the surrounding prose describes. Adds docs/TAILSCALE.md, docs/MIGRATE_EXISTING_DOCKER.md, docs/SIGNING.md and SECURITY_SELF_HOST.md. The migration guide covers the two things that bite an existing deployment: the volume must be chowned to uid 10001, and the H2 password must match the one the files were created with. HARDENING_NEXT_STEPS.md records what is genuinely not done, including the open items this branch does not close.
CI now builds and tests all three components, gates that every surface reports the same version, assembles the Android debug APK, produces a CycloneDX SBOM and scans the server image. Two jobs could never have passed as first written, so the pipeline was red on every run while looking like coverage: mobile-android could not resolve hermesEnabled because android/gradle.properties is gitignored (it holds the upload-key passwords), and container-scan pinned a trivy-action version that does not exist. The former now copies a committed gradle.properties.example, which also makes a fresh clone buildable; the latter is pinned to a real tag, and the job builds the jar itself since `needs:` orders jobs without sharing a workspace. Compose validation covers every shipped topology and asserts each one defaults to a loopback bind. The previous guard checked two files while three siblings published on 0.0.0.0. The SBOM step no longer pipes an unpinned install script from a moving branch into a shell.
ysalitrynskyi
force-pushed
the
hardening/security-baseline
branch
from
July 19, 2026 14:05
88d5ef6 to
af2b904
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security baseline for self-hosted ClipCascade, plus first-class Tailscale support.
Aimed at people running their own server on a private network. Nothing here changes the product's behaviour for existing users beyond the migration notes at the bottom.
What it changes
Server
CC_SERVER_DB_PASSWORDandCC_INITIAL_ADMIN_PASSWORDare required; the baked-in H2 default is gone. WithCIPHER=AES, a public default password also meant the at-rest encryption had a publicly known key.CC_ALLOWED_ORIGINS=*refuses to start. It was applied verbatim to both WebSocket endpoints, so any page a logged-in user visited could open a socket with their cookie and read their clipboard.X-Forwarded-Foris honoured only fromCC_TRUSTED_PROXY_CIDRS(unset by default), taking the right-most untrusted hop. Previously any client could set the header and choose the address the login lockout keys on.script-src 'self'; page scripts moved out of the templates into/assets/js/.CC_BIND_ADDRESS, defaulting to loopback.Clients
READ_LOGSand the overlay permission; capture is now the share sheet, a Quick Settings tile, and a notification action.*.ts.net.Ops —
SECURITY_SELF_HOST.md,docs/TAILSCALE.md,docs/MIGRATE_EXISTING_DOCKER.md,docs/SIGNING.md, and CI covering all three components plus compose validation and image scanning.Migration
For an existing deployment,
docs/MIGRATE_EXISTING_DOCKER.mdhas the detail. Three things matter:CC_SERVER_DB_PASSWORDto the value your database was created with — for anyone who never set it, that is the old baked-in default, and the space in it is significant (<file password> <user password>).chown -R 10001:10001the volume once; Docker does not re-own an existing volume for a non-root image.CC_ALLOWED_ORIGINS=*with exact origins, and setCC_BIND_ADDRESS.Also note
CC_SESSION_TIMEOUTnow defaults to 24h rather than roughly a year.Clients must be rebuilt from this branch. Protocol v2 is enforced end to end and the change that carries it over the default transport is client-side, so a released client will not sync against a server built from here.
Status
Tested end to end against a real server: two clients, encryption on, message relayed and decrypted. Desktop 50 tests, mobile 55, server 26; CI green.
Reviewed adversarially in several passes, including by independent models. That surfaced two serious defects that are fixed here — most importantly that the server relays only
payload/type/metadata, so the v2 envelope had to be nested insidepayloadrather than sent as new top-level fields. Without that, protocol v2 silently never took effect over STOMP.HARDENING_NEXT_STEPS.mdlists what is deliberately not done — unsigned mobile P2P signaling, TOFU pairing with no prompt, no rate limit on password change, soft-fail dependency scans, and more. Worth reading before assuming any area is complete.Happy to split this into smaller PRs if that is easier to review — the server, client and ops changes are largely separable, though the protocol change spans server and clients together.
In the meantime I am running this from a fork with prebuilt multi-arch images at
ghcr.io/ysalitrynskyi/clipcascade, so I can keep my own deployment on it. That is only for my own use while this is open, and none of that packaging is in this PR.