Security model
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.
| Destination | Made by | When | Payload | Opt-out |
|---|---|---|---|---|
Licence service (Supabase, EU, eu-north-1) | Editor and server | Activation, once at startup for an active licence, then every 6 hours | Licence and activation metadata, OS, application version, hardware fingerprint hash | None. 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.json | Editor only — the Java backend’s update checker | One GET at startup | None — a plain unauthenticated GET. Nothing is sent | DOCUCOMMIT_UPDATE_CHECK_ENABLED=false |
https://docucommit.se/download/latest.json (desktop-shell updater) | Editor only — the Tauri desktop shell, independently of the Java check above | Once at desktop shell startup, against the same endpoint | The Tauri updater plugin’s own check request. Install only proceeds if you confirm it | Not separately configurable today. Install is user-confirmed. |
https://api.github.com/user/emails | Server only | Only during a GitHub sign-in, and only if GitHub sign-in is configured | The signing-in user’s own GitHub access token, to read their verified email | Do not configure GitHub sign-in |
| Your Git remote | Editor and server | Clone, fetch, and push, when a user runs Publish or Get updates, or when the server syncs | Ordinary Git protocol traffic to the host you configured | Not 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 aconnect-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
preconnecthints in the HTML shell. The CSP blocks any stylesheet or font download, so nothing is fetched and nothing is sent — butpreconnectsits 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)
| Header | Value |
|---|---|
Content-Security-Policy | script-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-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=() |
Strict-Transport-Security | max-age=31536000; includeSubDomains — only 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 isSecure=falseso plain-HTTP local runs work; when you terminate TLS in front, addserver.servlet.session.cookie.secure=trueto your.env. - The session id is regenerated on login (session-fixation protection). Logout invalidates the
session server-side and deletes
DOCUCOMMITSESSIONIDandJSESSIONID. - CSRF protection uses a cookie-stored token and covers
POST /login,POST /logout,POST /license/refresh, and everyPOST,PUT,PATCH, andDELETEunder/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
401for 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/return503 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. scan → test → 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:
| Artefact | Signing |
|---|---|
| Server container image | Built 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 binaries | Azure Trusted Signing, SHA-256 digest, RFC-3161 timestamped |
macOS .dmg and .app | Apple Developer ID, hardened runtime, secure timestamp, submitted to Apple for notarization |
Linux .deb | Detached GPG signature (.asc) |
Everything, including server-image.tar.gz | SHA-256 checksum published beside the file |
| Release as a whole | SLSA 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_ALLandDOCUCOMMIT_NOINDEX_LIBRARIESaddnoindexheaders androbots.txtrules. 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:
| Path | Contents |
|---|---|
.auth/users.json | Accounts: email, display name, BCrypt hash, linked providers |
.comments/ | Comment threads, including author email and display name |
.docucommit-license/license.json | The 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.