samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
120 lines· 11.0 KB ·Markdown

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

  1. 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.
  2. 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.
  3. There are no quotas. Repository count, repository size and push size are all unlimited.
  4. 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.
  5. 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.
  6. gzip request bodies to git-upload-pack are decompressed without a size limit. The 64 MB limit applies to the compressed body only.
  7. 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:

  1. 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-sys dependency up to date.
  2. The git program parses untrusted packs in receive-pack and upload-pack, and runs with the same filesystem access as Openhub. Keep the system git patched.
  3. git archive honours a repository's own .gitattributes, including export-subst. That setting feeds attacker-controlled format strings into git's pretty-format engine, the route used by CVE-2022-41903 (heap overflow in git archive). A patched git closes that bug. The feature remains attack surface.
  4. receive.fsckObjects isn'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 .gitmodules and 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

  1. 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.
  2. 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.
  3. 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.
  4. 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)

  1. Register the administrator account first, before the server is reachable from anywhere else.
  2. Then set OPENHUB_DISABLE_REGISTRATION=true. Create later accounts by briefly re-enabling registration, or by having the administrator register them.
  3. Serve over HTTPS behind a reverse proxy, and set OPENHUB_PUBLIC_URL to the https:// URL. That also marks cookies Secure.
  4. Add rate limiting at the proxy for POST /login, POST /register and /*/*/info/refs.
  5. Keep git, the base image and Rust dependencies updated: use apt upgrade or rebuild the image, run cargo update, and rebuild.
  6. 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:

  1. Control who can sign up: invite-only registration, or administrator approval. Add moderation: list, disable and delete users and content.
  2. 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.
  3. Enable receive.fsckObjects, with receive.fsck.<msg-id>=ignore exceptions for common harmless defects.
  4. 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-subst never runs.
  5. 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.
  6. Return 401 for missing repositories over HTTP (as GitHub does), so private and missing repositories look the same.
  7. 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) or AppState::access (other channels), and must keep private repositories indistinguishable from missing ones.
  • Any new HTML built in Rust and marked |safe must 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.