Security
This page describes what Openhub protects against, how, and where it is still exposed. It is meant for anyone deciding how to deploy Openhub, and for anyone changing code that handles untrusted input.
Threat model
Trusted:
- The operator, who has shell access and owns the data directory.
- The site administrator, who has admin rights on every repository.
- Repository owners and collaborators, within their own repositories.
Untrusted:
- Anonymous visitors.
- Signed-in users acting on repositories they don't belong to.
- When registration is open, anyone on the internet, who can create an account and push arbitrary repositories of their own.
Untrusted input enters through:
- HTTP requests: forms, URLs, headers and smart HTTP bodies.
- SSH sessions: authentication attempts, exec commands and pack data.
- Repository content. Every object pushed by a user is later parsed by Openhub (libgit2, syntax highlighting, Markdown) and by
git(upload-pack,receive-pack,archive). - Markdown and text in profiles, repository descriptions, pull requests and comments.
Protections in place
| Area | Protection | Where |
|---|---|---|
| Passwords | Argon2id with a random salt per password. Verification runs even for unknown accounts, so timing doesn't reveal which accounts exist | src/web/account.rs |
| Sessions | 256-bit random tokens. Only their SHA-256 is stored. HttpOnly and SameSite=Lax cookies, Secure under HTTPS. Other sessions are revoked when the password changes |
src/web/ctx.rs, src/web/account.rs |
| CSRF | SameSite=Lax, plus an Origin check on every non-GET request outside git HTTP |
ctx::cross_origin |
| Open redirects | next and return_to must be same-site paths |
web::safe_next |
| XSS in pages | askama escapes every value. |safe is only used on HTML generated by Openhub. A strict CSP (script-src 'self', no inline script, object-src 'none', base-uri 'self') applies |
templates, ctx::middleware |
| XSS through Markdown | Raw HTML is sanitised by ammonia after rendering. Links get rel="noopener noreferrer nofollow" |
src/markdown.rs |
| XSS through hosted files | Raw files are served as text/plain (or as an image, or as an attachment), with nosniff and CSP: default-src 'none'; sandbox |
code::raw |
| Clickjacking | frame-ancestors 'none' on HTML pages |
ctx::middleware |
| SSH command execution | Only git-upload-pack and git-receive-pack are accepted, through a strict parser that rejects quotes, backslashes and NUL bytes. git is spawned directly with no shell. The repository path comes from the database ID |
src/ssh.rs |
| SSH access | Public-key authentication only. A 300 ms delay after each rejection. No shell, pty or subsystems | src/ssh.rs |
| Path traversal | No filesystem path is built from user input. Repository paths are repos/<id>.git. File paths inside a repository are looked up in git trees, not on disk |
Config::repo_path, src/git.rs |
| Private repository privacy | Unreadable repositories return 404 on the web and "Repository not found" over SSH | repo::load_repo, ssh.rs |
| Writing to repositories | Only SSH receive-pack (with write access) and web merges and branch deletions (with write access) change refs. Pushes can't write repository config or hooks, and no hooks are installed |
src/ssh.rs, src/web/pulls.rs, src/web/repo.rs |
| Openhub-managed refs | receive.hideRefs=refs/pull stops clients rewriting pull request heads |
git::configure |
| Merge races | The base branch is updated with a compare-and-swap, so a merge can't overwrite a concurrent push | git::merge |
| Pasted private keys | The key form rejects input that contains PRIVATE KEY |
validate::ssh_public_key |
| Rendering cost | Highlighting stops at 512 KB. Files over 1 MB aren't displayed. Diffs stop at 3,000 lines per file and 20,000 in total. The last-commit search stops at 5,000 commits. Request bodies are limited to 2 MB (64 MB for git-upload-pack) |
src/highlight.rs, src/web/code.rs, src/git.rs, src/web/mod.rs |
| Container | The Docker image runs as an unprivileged openhub user, which owns only /data |
Dockerfile |
Known gaps
The protections above suit an instance whose users are trusted. The gaps below matter most when registration is open, or when the server is reachable from the internet.
Abuse and resource exhaustion
- Registration is open by default, and the first account becomes administrator. Anyone who finds the URL can create an account, along with unlimited public and private repositories. If the server is reachable before the operator registers, a stranger can take the administrator account.
- There is no rate limiting on sign-in, sign-up, SSH authentication or HTTP Basic authentication. Each password check costs about 19 MB of memory and noticeable CPU. Sign-in runs one even for unknown accounts, and private HTTP clones run one per request. A flood of attempts can exhaust memory or CPU without needing an account.
- There are no quotas. Repository count, repository size and push size are all unlimited.
- git child processes have no timeouts or resource limits. A slow or huge push or fetch holds a process for as long as the client keeps the connection open, up to the 1-hour SSH inactivity timeout.
- The SSH input queue is unbounded. Data from the client is queued in memory faster than git can read it (see Git transport), so a fast client can grow server memory.
- gzip request bodies to
git-upload-packare decompressed without a size limit. The 64 MB limit applies to the compressed body only. - Some page loads are expensive for large repositories. The commit count on the repository home walks the full history. Pull request pages compute mergeability with an in-memory merge on every view. Pathological input can make syntax highlighting slow, within the 512 KB cap.
Code execution
Openhub's own code is Rust, so it is memory-safe. The real exposure is C code parsing repository data that users control:
- libgit2 runs inside the server process. It parses pushed objects to show trees, diffs and merges. An attacker controls those objects, and can trigger merges of crafted content in their own repository. A memory-corruption bug in libgit2 (there have been such CVEs, for example CVE-2024-24577) would compromise the whole process: every repository, the password hashes and the SSH host key. Keep the
git2/libgit2-sysdependency up to date. - The
gitprogram parses untrusted packs inreceive-packandupload-pack, and runs with the same filesystem access as Openhub. Keep the systemgitpatched. git archivehonours a repository's own.gitattributes, includingexport-subst. That setting feeds attacker-controlled format strings into git's pretty-format engine, the route used by CVE-2022-41903 (heap overflow ingit archive). A patched git closes that bug. The feature remains attack surface.receive.fsckObjectsisn't enabled, so malformed objects are accepted. Openhub can then store and serve objects crafted to attack the git clients of people who clone them, such as past.gitmodulesand symlink exploits. Enabling it rejects some older real-world repositories with harmless defects. That's why it's off, but the trade-off is different for a public instance.
Privacy and moderation
- An anonymous HTTP clone can tell a private repository from a missing one. The private one gets 401, the missing one 404 (see Accounts and access). The web interface and SSH don't leak this.
- Images in Markdown can load from any HTTPS host (
img-src https:), so a README can show the IP addresses and user agents of its readers to a third party. - There are no moderation tools. There's no admin list of users, no way to disable or delete accounts in the interface, and comments can't be edited or deleted. An administrator can delete any repository.
- There's no email verification, password reset, two-factor authentication or session list. Sessions aren't bound to an IP address or client.
Hardening advice
Private or team instance (registration off, trusted users)
- Register the administrator account first, before the server is reachable from anywhere else.
- Then set
OPENHUB_DISABLE_REGISTRATION=true. Create later accounts by briefly re-enabling registration, or by having the administrator register them. - Serve over HTTPS behind a reverse proxy, and set
OPENHUB_PUBLIC_URLto thehttps://URL. That also marks cookiesSecure. - Add rate limiting at the proxy for
POST /login,POST /registerand/*/*/info/refs. - Keep git, the base image and Rust dependencies updated: use
apt upgradeor rebuild the image, runcargo update, and rebuild. - Harden the container:
services: openhub: read_only: true cap_drop: [ALL] security_opt: ["no-new-privileges:true"] tmpfs: [/tmp] mem_limit: 1g pids_limit: 256
Public instance with open registration
Do everything above. In addition, before opening sign-ups, Openhub needs changes that don't exist yet. Roughly in priority order:
- Control who can sign up: invite-only registration, or administrator approval. Add moderation: list, disable and delete users and content.
- Add limits:
- A push size limit (
receive.maxInputSize). - Per-user repository count and disk quotas.
- Rate limits on authentication and pushes inside Openhub.
- A cap on concurrent password hashing.
- A bounded SSH input queue, with timeouts on git processes.
- A size limit on decompressed request bodies.
- A push size limit (
- Enable
receive.fsckObjects, withreceive.fsck.<msg-id>=ignoreexceptions for common harmless defects. - Sandbox git child processes. Use Landlock, seccomp, bubblewrap or nsjail so each process sees only its own repository (read-only for fetches and archives), has no network, and runs under CPU, memory and time limits. Alternatively, build archives with libgit2 so
export-substnever runs. - Move libgit2 work into a separate sandboxed worker process. This is the largest change, and the only one that contains a libgit2 memory-safety bug.
- Return 401 for missing repositories over HTTP (as GitHub does), so private and missing repositories look the same.
- Proxy external Markdown images, or limit them to the Openhub origin.
When you change this
- Any new handler that reads repository content must go through
load_repo(web) orAppState::access(other channels), and must keep private repositories indistinguishable from missing ones. - Any new HTML built in Rust and marked
|safemust escape every user-controlled value. Add a unit test showing that<script>comes out escaped. - Any new external process must be spawned without a shell, with arguments that come from validated or database-derived values, and with
kill_on_drop(true). - When a gap above is fixed, move it to the protections table.