Security model

self-hostingsecurity

Written for whoever has to sign off on running this. Everything below is checkable against the running system — headers with curl -I, outbound calls with tcpdump or your egress logs, signatures with cosign and gpg.

1. Network surface

The server listens on exactly one HTTP port — SERVER_PORT, default 8080, plain HTTP unless you set SERVER_SSL_ENABLED=true. There is no second admin port, no management endpoint, no JMX listener, no database socket. Inbound is one port.

Outbound, the server makes Git connections to the remote you configured — SSH or HTTPS, to your host, with your credentials — plus the two calls in the table below. Nothing else.

The editor binds 127.0.0.1 by default (DOCUCOMMIT_LOCAL_ADDRESS, port DOCUCOMMIT_LOCAL_PORT, default 8081). It is a single-user desktop application; nothing on the network can reach it unless an operator deliberately rebinds it.

2. Every outbound call

Five, in total, across both surfaces. This is the complete list.

DestinationMade byWhenPayloadOpt-out
Licence service (Supabase, EU, eu-north-1)Editor and serverActivation, once at startup for an active licence, then every 6 hoursLicence and activation metadata, OS, application version, hardware fingerprint hashNone. A licensed install must reach it; there is a 30-day offline grace window from the last successful heartbeat. Fully air-gapped deployment is not supported.
https://docucommit.se/download/latest.jsonEditor only — the Java backend’s update checkerOne GET at startupNone — a plain unauthenticated GET. Nothing is sentDOCUCOMMIT_UPDATE_CHECK_ENABLED=false
https://docucommit.se/download/latest.json (desktop-shell updater)Editor only — the Tauri desktop shell, independently of the Java check aboveOnce at desktop shell startup, against the same endpointThe Tauri updater plugin’s own check request. Install only proceeds if you confirm itNot separately configurable today. Install is user-confirmed.
https://api.github.com/user/emailsServer onlyOnly during a GitHub sign-in, and only if GitHub sign-in is configuredThe signing-in user’s own GitHub access token, to read their verified emailDo not configure GitHub sign-in
Your Git remoteEditor and serverClone, fetch, and push, when a user runs Publish or Get updates, or when the server syncsOrdinary Git protocol traffic to the host you configuredNot applicable — this is your own infrastructure

Notes a reviewer will want:

  • The licence call never carries document content, and no hostname. IP addresses are HMAC-hashed on the receiving side.
  • The update check is notify-only. It compares versions and shows a banner. It never downloads and never installs anything. Failures are swallowed silently. The desktop-shell updater is the one component that can install an update, and only after you click through its confirmation dialog.
  • There is no analytics, no telemetry, no crash or error reporting, no session recording, no CDN, and no web-font fetching. Every asset the pages need is served from the origin.
  • draw.io is vendored offline (v30.2.6, Apache-2.0). The bundle still contains some upstream endpoint strings for draw.io’s cloud and AI integrations — it was not stripped of every reference to them — but it never contacts them at runtime: the draw.io routes carry a connect-src 'self' blob: Content-Security-Policy, which blocks any request those leftover strings could otherwise produce. The policy is what makes “zero runtime requests” a guarantee rather than an observation.
  • One disclosed blemish: current builds still ship leftover Google Fonts preconnect hints in the HTML shell. The CSP blocks any stylesheet or font download, so nothing is fetched and nothing is sent — but preconnect sits outside CSP, so the browser opens idle DNS/TLS connections to two Google hosts. Removal is queued for a future release.
  • In the browser, the Content-Security-Policy on both surfaces sets connect-src 'self', so a page cannot originate a call to anywhere but its own origin. The calls above are made by the JVM or the desktop shell’s own process, not by page JavaScript.

Security reports: see https://docucommit.se/.well-known/security.txt.

3. Hardening in the product

Response headers (server)

HeaderValue
Content-Security-Policyscript-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policyaccelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()
Strict-Transport-Securitymax-age=31536000; includeSubDomainsonly when SERVER_SSL_ENABLED=true

No inline <script> is allowed. style-src permits 'unsafe-inline' — that is a real gap and it is there because the rendered pages use inline style attributes.

HSTS is deliberately omitted behind a proxy: a server that does not terminate TLS cannot know the public scheme, and an HSTS header pinned to the wrong one locks browsers out. Set it in the proxy instead — see Reverse proxy and TLS.

The editor sends the same headers, with one documented exception: the draw.io diagram surface gets a wider policy ('unsafe-inline', 'unsafe-eval', and blob: for scripts and workers) because the upstream draw.io application requires it. That surface is loopback-only, and its connect-src 'self' blob: policy blocks any request the bundle’s leftover cloud-integration endpoint strings could otherwise attempt.

Sessions and CSRF

  • Session cookie DOCUCOMMITSESSIONID, HttpOnly, SameSite=Strict. The shipped default is Secure=false so plain-HTTP local runs work; when you terminate TLS in front, add server.servlet.session.cookie.secure=true to your .env.
  • The session id is regenerated on login (session-fixation protection). Logout invalidates the session server-side and deletes DOCUCOMMITSESSIONID and JSESSIONID.
  • CSRF protection uses a cookie-stored token and covers POST /login, POST /logout, POST /license/refresh, and every POST, PUT, PATCH, and DELETE under /api/**. The token cookie is readable by JavaScript by design, so the front end can echo it back in the header.
  • HTTP Basic authentication is disabled outright.
  • The webhook is exempt from CSRF because it is not cookie-authenticated. It carries its own shared-secret header, compared in constant time, and returns 401 for everything while the secret is blank.

Credentials and errors

  • Passwords are hashed with BCrypt, 12 to 128 characters, control characters rejected.
  • Spring-rendered errors carry no message, no binding errors, no stack trace, and no exception class. The whitelabel error page is off.
  • The licence gate fails closed: with no valid licence key, /api/, /projects/, and /library/ return 503 license-required. Only /, /health, /robots.txt, /sitemap.xml, /webhook/, and static assets stay exempt.
  • The server never writes to your Git remote. It fetches and fast-forwards its local checkout; there is no commit and no push anywhere in its code path, so read-only Git credentials are enough. See Keep the server in sync.

Container

The server image is built on gcr.io/distroless/base-debian12 from a native-compiled binary. There is no shell, no package manager, and no interpreter inside it — which is also why operator tasks on the volume use a throwaway helper container rather than docker exec.

4. Supply chain

Every release runs the same pipeline, and the scan is the first job in it.

Scans gate the release. scantest → build. If either scanner fails, no artefact is produced:

  • Trivy filesystem scan over source, dependency manifests, and secrets — CRITICAL,HIGH, non-zero exit fails the job.
  • OWASP Dependency-Check against the NVD for every Java/Gradle dependency.

Signed artefacts:

ArtefactSigning
Server container imageBuilt with SBOM and provenance attestations, cosign keyless-signed (Sigstore, GitHub OIDC identity) in our private registry, then verified in the same CI job against the expected workflow identity
Windows .exe installer and binariesAzure Trusted Signing, SHA-256 digest, RFC-3161 timestamped
macOS .dmg and .appApple Developer ID, hardened runtime, secure timestamp, submitted to Apple for notarization
Linux .debDetached GPG signature (.asc)
Everything, including server-image.tar.gzSHA-256 checksum published beside the file
Release as a wholeSLSA provenance (multiple.intoto.jsonl) over the published digests

The cosign-signed image lives in our private registry and is verified by CI, not by you: it is not what you download. What you get from the download page is server-image.tar.gz, verified by its published SHA-256 checksum, and the desktop installers, verified by their platform signatures above. There is no customer-facing cosign verify step.

Commands for checking the checksum and the GPG signature, and the fingerprint of the signing key, are on the download page.

5. What sign-in does and does not protect

Be clear about this before you deploy.

The server has no built-in read authorization. Reading is public by design: the security configuration ends with anyRequest().permitAll(), and only the comment, mention, and notification endpoints require an account. Anyone who can reach the port can read every page, run every search, and download every export and attachment. Accounts exist for comment attribution, @mentions, and notification follows — nothing more. There are no roles and no per-library permissions. See Accounts and sign-in.

If the documentation must be private, put the restriction in front of the server:

  • Bind it to an internal network or VPN and do not expose it publicly.
  • Terminate at a reverse proxy that enforces authentication — mTLS, an OIDC proxy, SSO at the edge, or an allowlist — and pass only authenticated requests through.
  • On a public instance that should merely stay out of search results, DOCUCOMMIT_NOINDEX_ALL and DOCUCOMMIT_NOINDEX_LIBRARIES add noindex headers and robots.txt rules. That is a crawler hint, not access control.

6. Data at rest

Two places, and they belong to you.

Your documents live in your Git repository, on your Git host, in plain Markdown. The server keeps a read-only clone under <workdir>/<id> and treats the remote as the source of truth.

Server state lives in the working directory — the Docker volume — and is not in Git:

PathContents
.auth/users.jsonAccounts: email, display name, BCrypt hash, linked providers
.comments/Comment threads, including author email and display name
.docucommit-license/license.jsonThe signed licence token, written mode 0600
(workdir sidecars)Notification subscriptions and the outbox
.search-index/The Lucene index, rebuilt from the repository
<id>/The repository clone, one directory per configured repository

The server does not encrypt these files. They are ordinary files with ordinary permissions; if you need encryption at rest, encrypt the volume. Backing them up is a separate exercise from backing up your content — see Backup and restore.