GitVolt is alpha software. Only the latest build gets security fixes.
Please report vulnerabilities privately through GitHub's private vulnerability reporting on
this repository (Security › Report a vulnerability on GitVoltApp/gitvolt). Don't open a
public issue for a security problem.
A useful report says which build you ran, what an attacker needs (a crafted repository, a remote, a forge account, local access…), and the steps to reproduce it.
GitVolt is a desktop app: a Rust core that runs git and talks to forges, and a React UI in an
embedded Chromium (CEF). It has no server and no accounts of its own. What follows describes the
current code; each point names where to look.
- The UI runs in Chromium's multi-process sandbox. GitVolt asks the CEF runtime for
SandboxPolicy::Required(crates/gitvolt-app/src/lib.rs): if Chromium can't sandbox its processes, GitVolt refuses to start instead of running unsandboxed. - On Windows, Chromium sandboxes only when GitVolt runs as a DLL under CEF's
bootstrap.exe, which makes the sandbox broker (RunWinMainincrates/gitvolt-app/src/lib.rs,vendor/tauri-runtime-cef/GITVOLT-PATCH.md). The installers ship exactly that:GitVolt.exeis the bootstrap, from the same CEF build as the one linked, andGitVolt.dllthe app (scripts/package-windows.ps1checks the versions match). A release build without the broker refuses to start. A debug build stays onAuto: a plaingitvolt.exe, ascargo buildmakes it, runs unsandboxed and logs a warning. - On macOS, the helper apps in
GitVolt.app/Contents/Frameworksenter Chromium's Seatbelt sandbox through the framework'slibcef_sandbox.dylibbefore they load the framework itself. This works with the bundle ad-hoc signed (no Apple identity). GitVolt only runs from its bundle there, which always ships the library: a helper without it fails to start rather than run unsandboxed. - The
.deb,.rpmand Arch packages ship Chromium'schrome-sandboxhelper owned by root with the setuid bit (mode 4755). The packaging checks fail the build otherwise (scripts/check-deb.sh,scripts/package-arch.sh,scripts/package-rpm.sh). Where AppArmor restricts unprivileged user namespaces, Chromium is sent straight to that helper (--disable-namespace-sandbox); the sandbox stays on. For development builds, see docs/dev-setup.md. - Release builds ignore Chromium switches given on the command line, so the installed binary
can't be started with
--remote-debugging-portor--disable-web-security. DevTools are off in release builds (vendor/tauri-runtime-cef).
- GitHub and GitLab personal access tokens are kept in the system keyring, through the Secret
Service D-Bus API (GNOME Keyring, KWallet, KeePassXC…) via the
keyringcrate (crates/gitvolt-forge/src/tokens.rs). Each is an item in the default collection with the attributesservice=gitvoltandusername=<profile id>/<host>. - GitVolt was called GitBolt before 0.7.0. A token only in GitBolt's item (
service=gitbolt) is copied to GitVolt's the first time it's used, read back, and only then is GitBolt's item deleted. On macOS the Keychain may ask once per token to let GitVolt read it. - When no Secret Service is available, or it refuses the token, the token goes to a fallback
file,
~/.local/share/gitvolt/forge-tokens: mode 0600 in a 0700 directory you own, written through a temporary file and a rename, never through a symbolic link. Settings › Accounts marks such an account "File — not secure". Once a keyring is available, GitVolt moves the token there the first time it uses the account after a restart, then deletes the file copy. - Tokens never go into the settings or profile files: a profile records the host, the user and where the token is, not the token.
- A token is sent only in the
Authorizationheader, and only to its own account's host. API requests follow no redirects. An image redirect is re-checked, and the token never goes to another origin (crates/gitvolt-forge/src/http.rs). - In memory a token is a
Secrettype that never prints or serializes its value. As a second line of defence, every log line, git error and message shown in the UI passes through a redaction filter (crates/gitvolt-core/src/redact.rs). The filter removes URL credentials, token query parameters,Authorizationheaders and known GitHub and GitLab token formats. - Removing an account (Settings › Accounts) deletes its token from the keyring and from the fallback file. It doesn't revoke the token on the forge.
gitandsshrun the GitVolt binary itself asGIT_ASKPASS/SSH_ASKPASS(crates/gitvolt-core/src/askpass.rs). It forwards the prompt to the running app over a Unix socket created for this session in$XDG_RUNTIME_DIR, or in a private 0700 folder in the temp directory when that variable isn't set. The socket is mode 0600 and only accepts a peer with your user id (SO_PEERCRED). Each request must also carry a random 128-bit session token, which is compared in constant time.- Only a network operation that you started can show a prompt. GitVolt's own background fetch
is denied at once and never prompts. It also sets
GCM_INTERACTIVE=never, so Git Credential Manager doesn't prompt either. - Every git command runs with
GIT_TERMINAL_PROMPT=0. Network commands run in a new session with no controlling terminal, so nothing can prompt on the terminal GitVolt was started from. sshgetsSSH_ASKPASS_REQUIRE=force: passphrase, PIN and host key questions all come to GitVolt's dialog.- GitVolt doesn't keep your answers. A git credential helper you configured may keep them, as it would in a terminal.
- Network commands also run with
-c protocol.ext.allow=never, which disables git'sext::transport.
MR/PR descriptions and comments, and Markdown files in File View and Diff View, are rendered from
untrusted text (ui/src/markdown/).
-
Raw HTML is parsed, then sanitized with
rehype-sanitize, using GitHub's schema with these changes (sanitize.ts):script,style,iframe,object,embed,svg,math,noscript,template,textarea,selectandtitleare removed along with their contents;- no
classattribute, exceptlanguage-*on code, and nostyle,tabindex,accesskeyor form attributes; idandnameget auser-content-prefix;- links may only be
http,https,mailtoor relative.data:images are allowed only as base64 PNG, GIF, JPEG or WebP.
-
A file's front matter is parsed as plain data (YAML's core schema: no custom tags, a capped alias expansion; or TOML), and its values render as text, never as Markdown or HTML. A block that doesn't parse shows as a code block.
-
Issue references, the front matter table, and the marks a rendered diff adds, carry a random nonce made for each render. A value the document writes itself doesn't match the nonce and is dropped, so a document can't forge them (
render.tsx). -
Links are rendered without an
href, so the webview never navigates. A click goes through the core, which opens onlyhttp,httpsandmailtolinks in your default browser (crates/gitvolt-core/src/links.rs). -
The webview never loads a remote image: every
<img>shows adata:orblob:URL. The core fetches the bytes. Images on the forge's own hosts load automatically. Images on any other host wait until you click "Load image from<host>", and are then fetched over https without any token. See PRIVACY.md. -
Mermaid diagrams render with
securityLevel: 'strict'and are shown as an SVG image, in which scripts don't run. -
A Content Security Policy is the second layer. The UI reaches the Rust core through a single IPC command (
crates/gitvolt-app/src/lib.rs), so a script that ran in the page would have the UI's full powers. The policy (app.security.cspincrates/gitvolt-app/tauri.conf.json) keeps one from running even if it got past the sanitizer:default-src 'self': nothing loads from outside the app's bundle;script-src 'self' 'wasm-unsafe-eval': only the bundle's scripts run, plus WebAssembly for the syntax highlighter (Shiki's Oniguruma engine). Nounsafe-eval, nounsafe-inline: an inline<script>or anonerror=attribute is blocked. Tauri adds the hashes of the bundle's own inline scripts (the first-paint theme script inui/index.html, and one per bundled script file);style-src 'self' 'unsafe-inline': the editor (Monaco) and the UI set inline styles. This is the policy's one loose part: injected CSS could restyle the page, but can't run code or load anything from outside;img-src 'self' data: blob:: the core hands over every remote image asdata:orblob:;media-src 'self' blob:: a video in rendered Markdown plays from ablob:URL of the bytes the core fetched, and audio and video in the diff and File View from ablob:URL of the file's bytes;- no
font-src(sodefault-src 'self'): a font file in the diff and File View is previewed from its bytes (aFontFacebuilt from a buffer, never fetched from a URL), which pass through Chromium's font sanitizer (OTS) like any web font; connect-src 'self' ipc: http://ipc.localhost: the app's own IPC only;object-src 'none',base-uri 'none',frame-ancestors 'none',form-action 'none'.
Web Workers (Monaco's, the Markdown parser's) run under the same policy. A worker takes its policy from its own script's response, and Tauri sends one only with the page, so the app adds the policy to every other response of its bundle (
crates/gitvolt-app/src/csp.rs):evalis blocked in a worker as in the page. -
The page may read the clipboard's text (the clipboard plugin's
read-textpermission,crates/gitvolt-app/capabilities/default.json), for Ctrl+V's "Go to what's on the clipboard"; the UI reads it only then. It goes through the plugin because Chromium's ownnavigator.clipboard.readText()asks for a permission in the app, and that prompt never answers. Like the rest of the IPC, it is reachable only by the bundle's own scripts (the CSP above). -
The end-to-end tests run the UI under the same policy (sent by
vite previewwith every file, worker scripts included,ui/vite.config.ts), and fail on any violation (ui/e2e/test.ts).ui/e2e/csp.spec.tschecks that a script injected into rendered Markdown never runs.
An .html, .htm or .xhtml file shows rendered in File View and Diff View
(ui/src/diff/pageDoc.ts, HtmlPage.tsx). Its markup is untrusted. Two layers keep it from
running, and these are the guarantee:
- The sandbox. It shows in an
<iframe sandbox>with no permissions: no scripts, an opaque origin (no access to the app or its IPC), no forms, popups or navigation of the app. Its content comes throughsrcdoc. - The Content Security Policy. A
srcdocdocument inherits the app's policy, and GitVolt adds a stricter one first in its head:default-src 'none', with only inline styles anddata:images, fonts and media. Nothing is fetched, from the network or from the app's own origin.
On top of these, best-effort hardening, which the guarantee doesn't rely on:
<script>elements,on…handlers,javascript:URLs,<link>s that weren't inlined,pingandattributionsrc,<base>,<meta http-equiv="refresh">and nestedsrcdocare taken out before it shows, and every link opens a new window, which the sandbox refuses: a click does nothing.- Relative images and stylesheets are read from the repository (at the commit shown) and inlined
as
data:URLs and<style>before that clean-up, with</styleescaped in the CSS. The result is parsed and cleaned again until it doesn't change, so markup that reads differently once parsed again (SVG or MathML namespace confusion) is cleaned as the frame will read it. - Scripts and files on other hosts don't load; a bar names those hosts, and Open in browser opens the file outside the app, through the system's Open With chooser.
A PDF in File View or Diff View is untrusted bytes from the repository, drawn by pdf.js
(pdfjs-dist, ui/src/pdf/pdfjs.ts), not by Chromium's PDF viewer:
- It is parsed in pdf.js's own Web Worker; the page only draws what the worker sends (canvases, and a text layer of plain text nodes for selection).
- No annotation layer, forms, XFA or PDF JavaScript: links and scripts in the document are never
rendered or run. Annotations' appearances (stamps, filled-in fields) are drawn on the canvas, as
pixels only. pdf.js 6 has no
evalpath at all, and its worker runs under the app's policy like the others, which would block one. - It needs no change to the CSP: the worker and the JPEG 2000 and JBIG2 decoders' WebAssembly
are the bundle's own files (
'self','wasm-unsafe-eval'). Chromium's built-in viewer would needobject-srcandframe-srcopened toblob:, and runs PDFium in a frame of the page. - Keep
pdfjs-distcurrent: a parser bug that ran code here would run it with the UI's powers (CVE-2024-4367 was one, fixed in pdf.js 4.2.67).
GitVolt runs git the way you would in a terminal, so a repository's hooks and config apply.
That is a trust boundary.
- Writes run hooks. A commit runs
pre-commit,commit-msgand the other commit hooks. Merge, rebase, checkout and push run theirs. GitVolt has no option to skip them. - Submodules: Update runs hooks inside the submodules.
git submodule updatechecks out each submodule, which runs that submodule's ownpost-checkouthook and reads its own config, and it clones from the URLs in.gitmodules, which the repository chooses. Update only repositories whose submodules you trust, as in a terminal. - Opening a repository runs no commit hooks, but it does run git in it. What runs:
- Read commands:
git worktree listandgit statusas soon as the tab opens, then, as you browse,git log,git diff,git show,git blameand the like. git reads the repository's own.git/configfor each of them, so a command it names can run:core.fsmonitorruns ongit status, as it does in a terminal and in every git client. - A background fetch, unless it's Off (Settings › Fetch › Background fetch interval: Off).
When the tab opens, GitVolt runs
git fetch --allat once if the last fetch is older than the interval (always, the first time), then once per interval.git fetchruns thereference-transactionhook when it updates refs, and then git's auto maintenance (git maintenance run --auto), which can runpre-auto-gc. - Diffs run with
--no-ext-diff --no-textconv, so external diff and textconv drivers don't run.
- Read commands:
- Ownership. git's
safe.directorycheck applies unchanged: a repository owned by another user doesn't open ("detected dubious ownership"), unless your own git configuration lists it insafe.directory. GitVolt never setssafe.directoryitself, and its own reads (throughgix) open such a repository with reduced trust only until git refuses it. - To open a repository you don't trust without running its fetch hooks, set the background fetch
interval to Off first.
git statusand the other reads still run, so itscore.fsmonitorstill applies: open it only if you'd also rungit statusin it.
Open in default app (Open in browser for an HTML file) hands a file to the program your system opens it with. GitVolt never uses it to run a repository's file. Before anything starts, the core refuses a file the system would execute or launch rather than open:
- by its extension, on every OS: executables, scripts, installers and shortcuts (
.exe,.bat,.cmd,.ps1,.msi,.lnk,.url,.jarand the like), and the launchers and scripts a Linux desktop or macOS may run (.desktop,.sh,.command,.app,.AppImage,.webloc,.scptand the like); - on Linux and macOS, a file with any execute permission;
- on Windows, a type the shell itself calls dangerous (
AssocIsDangerous).
It checks both the name the file has in the repository and the file it resolves to, so a symlink to an executable is refused too. An old version opens as a read-only copy (or as the working-tree file, when that has the same bytes), checked by the name it's shown under. The menu shows the entry disabled for such a file, and Other… or an editor still opens it.
GitVolt can download and install its own updates from GitHub Releases
(crates/gitvolt-core/src/updates/, crates/gitvolt-forge/src/updates.rs).
-
Checksums. A downloaded package is kept only if the same release's
SHA256SUMSlists it with the same SHA-256 digest. A mismatch, a packageSHA256SUMSdoesn't list, or a release withoutSHA256SUMSdeletes the download and says so; it is never run. Right before the install, the digest is checked again, so a file changed in the meantime is deleted too. -
Where downloads come from. The release list comes from
api.github.comwithout any token, fromGitVoltApp/gitvoltonly. A package is fetched only fromgithub.com/GitVoltApp/gitvolt/releases/download/(the owner and repo in any case, as GitHub takes them; no.,..or encoded/,\or.in the path), and a redirect is followed only to GitHub's asset storage (objects.githubusercontent.com,release-assets.githubusercontent.com), over https, through the same HTTP client (TLS, proxy, timeouts) as the forge API. The download goes to~/.cache/gitvolt/updates/(0700;%LOCALAPPDATA%\gitvolt\updateson Windows,~/Library/Caches/GitVolt/updateson macOS). -
What
SHA256SUMSproves. It catches a corrupted or swapped download, but it comes from the same release, so it only proves the file is the one published there. The packages aren't code-signed yet. For a stronger check, GitHub's build provenance attestations tie each package to the release workflow that built it; verify one by hand with the GitHub CLI, giving--repothe repository the release came from. That isGitVoltApp/gitvolt:gh attestation verify GitVolt_0.7.0_amd64.deb --repo GitVoltApp/gitvolt
A package built before the move was signed as
GitBoltApp/gitbolt: verify it with--repo GitBoltApp/gitbolt. -
The install. GitVolt runs
pkexec apt install -y /<path>/<package>.deb,pkexec dnf install -y /<path>/<package>.rpm(on openSUSEpkexec zypper --non-interactive install --allow-unsigned-rpm, elsepkexec rpm -U) orpkexec pacman -U --noconfirm <package>as an argument list (no shell), with the absolute path of the download it has just checked againstSHA256SUMS, so your desktop's polkit prompt asks for the password; GitVolt never sees it and never usessudoitself. Ifpkexecis missing or you cancel, it shows thesudocommand to run yourself. On Windows it starts the installer (the NSIS setup, ormsiexec /ifor the MSI) and quits. On macOS it runs a fixed script as you (/bin/sh -c, the two paths as arguments, never spliced into it): it mounts the.dmgread-only and unbrowsed, copies its GitVolt.app withdittobeside the running one and swaps them by rename, putting the old one back if that fails, never asking for an administrator password. Where the app's folder isn't writable it shows the manual steps instead. GitVolt's downloads carry no quarantine flag (it isn't App Sandboxed and doesn't setLSFileQuarantineEnabled), so Gatekeeper doesn't check the copy again:SHA256SUMSis the check, as on the other platforms. -
Which package. Each package names how it was installed in an
install-kindfile beside the binary (deb,arch,rpm,nsisormsi), or in the bundle'sContents/Resources(dmg); a build from source has none and is only offered the release page.
- The end-to-end test harness (
crates/gitvolt-harness) is a separate binary. It serves the API over a WebSocket on127.0.0.1, with/test/*routes. It accepts WebSocket upgrades only fromlocalhost,127.0.0.1ortauri://origins, and refuses writes outside fixture repositories. The packages don't include it: they contain only thegitvoltbinary and CEF's files. - Test-only API requests are compiled only with
gitvolt-core'stestingfeature, and only the harness turns it on. The packaged app is built fromcrates/gitvolt-appalone, which doesn't. Acargo build --workspacewould unify the feature intogitvolt-appas well, so a release build ofgitvolt-appwith it on fails to compile (a constant assertion incrates/gitvolt-app/src/lib.rs). Debug workspace builds, used for tests and lints, are unaffected. - The UI's test hooks (
window.__gband the minimized-window override) are compiled only into development and e2e builds. The WebSocket transport is used only when the page isn't running inside the app.