The prior model claimed git-sync's add-set is scoped to the configured `folders` (pages/config/themes), so accounts/ and user/env/ were "safe by construction." That was wrong: prod auto-commit 9337003 pushed the whole user/env/<host>/config tree (JWT secret, CSRF salt, git-sync token + webhook secret), accounts/mischa.yaml, and system.yaml to Gitea — all outside the configured folders. git-sync stages everything under user/ not gitignored; .gitignore is the only reliable exclusion. - Rewrite the architecture-patterns doc around the corrected predicate and document the incident + remediation (gitignore /env/, rotate token+webhook). - Correct git-sync-notes.md: env/ is NOT safe on folder scope; drop the "never reaches Gitea" claim; fix the secret-files table intro + add /env/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
13 KiB
title, date, module, problem_type, component, severity, category, applies_when, tags
| title | date | module | problem_type | component | severity | category | applies_when | tags | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap) | 2026-07-05 | git-sync | architecture_pattern | tooling | high | architecture-patterns |
|
|
Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)
Correction (2026-07-05): An earlier version of this doc claimed the sync add-set was scoped to the configured
folders, and concluded thataccounts/anduser/env/were "safe by construction" because they sit outsidepages/config/themes. That model is wrong and caused a live secret leak. git-sync's auto-commit stages files outside the configured folders; the only reliable exclusion is.gitignore. The corrected model is below.
Context
The intotheeast.com Grav site runs the git-sync plugin on production in
bidirectional mode: direction: both, on_save: true, a webhook at
/_git-sync, and configured folders: pages, config, themes. Bidirectional
means the server both pulls content authored elsewhere and pushes
content authored on the server (Admin edits, /post submissions, uploads) back
to the shared Gitea repo.
The operator's question was: "Can I safely enable bidirectional prod → Gitea sync without leaking secrets?" Production holds things that must never reach a shared repo — API tokens, JWT signing secrets, CSRF salts, the git-sync token itself.
The dangerous, tempting answer is "the plugin only syncs pages/config/themes,
so anything outside those folders is safe." This is false, and acting on it
leaked secrets. See the incident below.
The incident (what actually happened)
Production's git-sync auto-commit 9337003 ("(Grav GitSync) Automatic Commit
...") pushed the entire user/env/intotheeast.com/config/ tree to Gitea —
including api-private.php (JWT secret), security-private.php (CSRF salt), and
git-sync.yaml (the sync token + webhook secret) — plus accounts/mischa.yaml
(password hash), system.yaml, and post-form.md.
None of those paths is under the configured folders: pages, config, themes.
user/env/ is a sibling of user/config/, not a subfolder of it. Yet git-sync
staged and pushed them anyway. That single fact refutes the "folders scope the
add-set" model empirically: the folders setting does not scope what the
auto-commit stages. Whatever git-sync's exact git add invocation, the
operational truth is that its commit sweeps the whole user/ working tree.
Remediation: disable sync → .gitignore /env/ and git rm --cached it →
reset --hard prod to the gitignored state → regenerate the leaked JWT/CSRF
salts (delete the *-private.php files; Grav regenerates them) → rotate the
Gitea token and webhook secret (they were exposed in git-sync.yaml). Rotation
is what actually neutralizes the leak; the history rewrite is optional for a
private repo.
Guidance
The safety question reduces to one predicate — but not the one the original doc used:
A file round-trips to the shared repo if and only if it is NOT gitignored. The configured
folderssetting does not narrow this. Treat the round-trippable set as everything underuser/that git will track — i.e. everything not matched byuser/.gitignore.
Consequences, corrected:
-
.gitignoreis the only reliable boundary. Do not rely on a file being "outside the synced folders." If it is underuser/and not gitignored, a bidirectional sync can push it. Design exclusions with.gitignore, and verify withgit -C user status/git -C user check-ignore <path>. -
accounts/is NOT structurally excluded.user/accounts/*.yaml(bcrypt password hashes) is a tracked content folder and is not gitignored, so it does round-trip —accounts/mischa.yamlwas in the leak commit. If you need an account file to stay server-local, it must be gitignored explicitly (asaccounts/testrunner.yamlalready is). The earlier "password hashes never leave the server" claim was wrong. -
The per-environment tree
user/env/<host>/MUST be gitignored — it is not inherently safe. It holds the live git-sync token, JWT secret, and CSRF salt (Grav writes all server-side Admin config there once the env dir exists). Because it is not underuser/config/people assumed it was outside the sync scope; the incident proved it is not. It is now gitignored (/env/inuser/.gitignore, commit6e8eadb). Keep it that way.Side effect worth remembering: once
user/env/<host>/exists, Grav's Admin writes all config changes there (system and plugin), not intouser/config/. So server-side Admin edits are server-only — but "server-only" now depends entirely on/env/being gitignored, not on folder scope. When auditing, check bothuser/config/...anduser/env/<host>/config/...(env wins at runtime). Seedocs/working/git-sync-notes.md. -
Per-install secrets go in gitignored companion files. Grav's convention splits a per-install secret out of the functional YAML into a sibling that is gitignored: the JWT secret in
api-private.php, the CSRF/nonce + rate-limit salt insecurity-private.php, plussecurity.yamlandversions.yaml. These are safe because they are gitignored, not because of where they sit.
Run every secret through predicate #1 (is it gitignored?) and the answer falls out — but you must actually enumerate what is not gitignored, not what is "outside the folders."
The tracked-file boomerang (a separate trap)
Independently of the folder-scope error above, there is a second trap that the original doc got right and that still holds: a per-install value that a plugin regenerates at runtime and persists into a tracked, functional config file.
The concrete case: the api plugin's popularity feature generates
popularity.salt and writes it into user/config/plugins/api.yaml — a file
that also carries must-be-shared functional config. That file is tracked, so on
prod the popularity feature regenerates the salt, git-sync stages the change,
commits, and pushes prod's salt back to the shared repo. Another environment
pulls it, regenerates its own salt, pushes again. The value ping-pongs across
installs, producing endless noise commits.
The critical realization: you cannot gitignore a single key inside a file that
also carries functional config. .gitignore operates on whole files. api.yaml
must be tracked because the rest of it must be shared; therefore the salt inside
it is tracked too; therefore it boomerangs.
The rules
For any per-install runtime-generated value, pick one of exactly three resolutions — and do not reach for the fourth (stripping the line), which cannot work under sync:
- Isolate the value into a gitignored companion
<name>-private.php— the pattern Grav uses for the JWT secret viaapi-private.php. - Disable the feature that generates it (e.g.
popularity.enabled: false). - Consciously accept the churn when the value is genuinely low-stakes
(
popularity.saltis an IP-hashing salt, not a credential).
Do not keep stripping the value from the tracked file — bidirectional sync brings it right back on the next save.
Before / after: what sticks and what doesn't
A standalone file gitignored + untracked sticks. For security-private.php
(a file that contains only the secret) or the whole user/env/ tree:
git -C user rm --cached -r config/security-private.php # or: rm --cached -r env
printf '/config/security-private.php\n/env/\n' >> user/.gitignore
This works permanently. The path is no longer tracked, so the sync's add-set skips it forever. The fix sticks because the secret owns its own gitignored path.
A key inside a tracked functional file — stripping the line does NOT stick.
For popularity.salt inside api.yaml, deleting just the salt: line and
committing looks clean locally, but Grav re-appends it at runtime and the next
sync re-commits and re-pushes it. The only durable fixes are the
companion-private-file pattern or disabling the feature.
Why This Matters
Two quiet, cross-environmental failure modes:
-
The folder-scope illusion. Assuming "only
pages/config/themessync" is a security control leads you to leave secrets inenv/oraccounts/unignored — and a single Admin save on prod pushes them to a shared repo. This actually happened here. The only defensible mental model is gitignore is the boundary; enumerate the un-ignored set, not the "un-foldered" set. -
The boomerang. A "cleanup" commit that strips a secret from a tracked file looks done locally but silently reappears upstream on the next content save, because the plugin regenerates it and the sync re-commits it.
Getting both right is what lets you answer "is bidirectional sync safe?" honestly.
The answer is yes, once user/.gitignore actually excludes every sensitive
path — env/, the per-install *-private.php files, security.yaml,
versions.yaml, and any account file that must stay server-local — and once every
runtime-regenerated value either lives in its own gitignored file or is a
consciously-accepted low-stakes churn. It is emphatically not safe on the
strength of folder scoping alone.
When to Apply
- Enabling or auditing bidirectional git-sync on a server that authors
content. Enumerate the round-trippable set as everything under
user/not matched by.gitignore— then confirm no secret is in it. - Deciding where a new secret or per-install generated value should live. Standalone gitignored file for anything sensitive; never a key inside a shared functional YAML; never "outside the folders" as the sole justification.
- Reviewing a "stop tracking this secret" cleanup for whether it will stick: is the secret in its own gitignored path (holds) or a line inside a tracked functional file that something regenerates (boomerangs)?
Examples
The leak (what "folder scope is safe" cost). With folders: pages, config, themes and /env/ not gitignored, prod's auto-commit 9337003 pushed
user/env/intotheeast.com/config/** (JWT, CSRF salt, git-sync token + webhook
secret), accounts/mischa.yaml, system.yaml, and post-form.md to Gitea —
all outside the configured folders. Fix: gitignore + untrack /env/, regenerate
the JWT/CSRF salts, rotate the token and webhook secret.
Safe after remediation. Same bidirectional config, but now user/.gitignore
excludes /env/, config/plugins/git-sync.yaml, config/plugins/api-private.php,
config/security.yaml, config/security-private.php, config/versions.yaml.
Running each secret through is-it-gitignored: all sensitive paths are excluded →
none is in the round-trippable set. Verified: prod's git status shows only the
intended tracked content, and no boomerang/secret commit lands on the remote.
Boomerang example. popularity.salt in the tracked api.yaml regenerates
per-install and re-commits under sync. The fix that sticks is the
companion-private-file pattern or popularity.enabled: false — not stripping
the salt: line.
Related
docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md— the origin of the "functional plugin config in trackeduser/config/plugins/, secrets/per-install values in gitignored*-private.php" rule. That doc covers where config must live to deploy; this doc covers why gitignore — not folder scope — is the sync boundary, and why a runtime-written tracked value boomerangs.docs/working/git-sync-notes.md— operational notes on git-sync's synced folders and the per-environment tree. Corrected in the same 2026-07-05 pass to drop the "env/ is outside the sync scope so it's safe" claim.docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md— adjacent context from the same Grav production cutover, different failure mode.