Skip to content

feat: security baseline + Tailscale self-host support#163

Open
ysalitrynskyi wants to merge 6 commits into
Sathvik-Rao:mainfrom
ysalitrynskyi:hardening/security-baseline
Open

feat: security baseline + Tailscale self-host support#163
ysalitrynskyi wants to merge 6 commits into
Sathvik-Rao:mainfrom
ysalitrynskyi:hardening/security-baseline

Conversation

@ysalitrynskyi

@ysalitrynskyi ysalitrynskyi commented Jul 16, 2026

Copy link
Copy Markdown

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_PASSWORD and CC_INITIAL_ADMIN_PASSWORD are required; the baked-in H2 default is gone. With CIPHER=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-For is honoured only from CC_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.
  • CSP is script-src 'self'; page scripts moved out of the templates into /assets/js/.
  • Password changes verify the current password server-side and revoke sessions.
  • Container runs as uid 10001; compose binds one host address via CC_BIND_ADDRESS, defaulting to loopback.

Clients

  • Secrets move to the OS keyring (desktop) and Keychain/Keystore (mobile), migrated out of the plaintext config file.
  • Clipboard protocol v2: device id, counter and timestamp bound inside the ciphertext, with replay rejection. Legacy v1 is refused by default.
  • Android drops READ_LOGS and the overlay permission; capture is now the share sheet, a Quick Settings tile, and a notification action.
  • Cleartext HTTP is allowed only for loopback, RFC1918, Tailscale CGNAT and *.ts.net.

OpsSECURITY_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.md has the detail. Three things matter:

  1. Set CC_SERVER_DB_PASSWORD to 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>).
  2. chown -R 10001:10001 the volume once; Docker does not re-own an existing volume for a non-root image.
  3. Replace CC_ALLOWED_ORIGINS=* with exact origins, and set CC_BIND_ADDRESS.

Also note CC_SESSION_TIMEOUT now 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 inside payload rather than sent as new top-level fields. Without that, protocol v2 silently never took effect over STOMP.

HARDENING_NEXT_STEPS.md lists 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.

@ysalitrynskyi
ysalitrynskyi marked this pull request as draft July 17, 2026 01:29
@ysalitrynskyi
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
ysalitrynskyi force-pushed the hardening/security-baseline branch from 88d5ef6 to af2b904 Compare July 19, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant