diff --git a/docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md b/docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md new file mode 100644 index 0000000..451a3ac --- /dev/null +++ b/docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md @@ -0,0 +1,243 @@ +--- +title: "Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)" +date: 2026-07-05 +module: git-sync +problem_type: architecture_pattern +component: tooling +severity: high +category: architecture-patterns +applies_when: + - "Enabling bidirectional Grav git-sync (direction: both, on_save: true) so prod can push content back to Gitea" + - "Auditing whether the server can leak secrets (tokens, password hashes, signing salts) off the production host" + - "A per-install runtime-generated value is being persisted into a tracked config file that also carries functional config" + - "Deciding where a per-install secret must live so it never round-trips" + - "A config value keeps ping-ponging or re-committing itself across environments after each sync" +tags: + - git-sync + - secret-exposure + - gitignore + - grav + - popularity-salt + - config-boundary + - bidirectional-sync + - per-install-secret + - env-tree-leak +--- + +# 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 that +> `accounts/` and `user/env/` were "safe by construction" because they sit +> outside `pages/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 `folders` setting does **not** narrow this. Treat the +> round-trippable set as *everything under `user/` that git will track* — i.e. +> everything not matched by `user/.gitignore`. + +Consequences, corrected: + +1. **`.gitignore` is the only reliable boundary.** Do not rely on a file being + "outside the synced folders." If it is under `user/` and not gitignored, a + bidirectional sync can push it. Design exclusions with `.gitignore`, and + verify with `git -C user status` / `git -C user check-ignore `. + +2. **`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.yaml` was in the leak commit. If you + need an account file to stay server-local, it must be gitignored explicitly + (as `accounts/testrunner.yaml` already is). The earlier "password hashes never + leave the server" claim was wrong. + +3. **The per-environment tree `user/env//` 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 under `user/config/` people assumed it was outside + the sync scope; the incident proved it is not. It is now gitignored + (`/env/` in `user/.gitignore`, commit `6e8eadb`). Keep it that way. + + > Side effect worth remembering: once `user/env//` exists, Grav's Admin + > writes **all** config changes there (system and plugin), not into + > `user/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 **both** `user/config/...` and `user/env//config/...` + > (env wins at runtime). See `docs/working/git-sync-notes.md`. + +4. **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 in `security-private.php`, plus `security.yaml` and `versions.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 `-private.php`** — the + pattern Grav uses for the JWT secret via `api-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.salt` is 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: + +```bash +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: + +1. **The folder-scope illusion.** Assuming "only `pages/config/themes` sync" is a + security control leads you to leave secrets in `env/` or `accounts/` 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. + +2. **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 tracked `user/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. diff --git a/docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md b/docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md index 36df1f7..731d0b0 100644 --- a/docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md +++ b/docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md @@ -82,4 +82,5 @@ The failure is silent and per-environment: it works on the developer's machine ( - `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the config gap documented here was the *other* latent problem surfaced in that same investigation: the `api` plugin also had to be *installed* first before any config could take effect. The install gap (GPM version floor) and this config-tracking gap compounded each other on the fresh prod environment. - `docs/working/git-sync-notes.md` — the related third config location: on prod, Grav Admin saves config into the per-environment tree `user/env//config/`, which is *also* untracked. Same "config that doesn't reach the repo" family. +- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — the sync-boomerang consequence of this rule: a per-install value that a plugin regenerates into a *tracked* functional config file (e.g. `popularity.salt` in `api.yaml`) re-commits itself and ping-pongs across environments under bidirectional git-sync. The `*-private.php` companion pattern this doc establishes is exactly the durable fix. - **CLAUDE.md §0 (plugin-management model):** only `pages/`, `config/`, `accounts/`, `themes/` are tracked in the `user/` repo; `plugins/` and `data/` are gitignored and GPM-managed. That tracking boundary is exactly why functional config must live under `config/plugins/`, not in the plugin's own folder. diff --git a/docs/working/git-sync-notes.md b/docs/working/git-sync-notes.md index b1c5045..473a427 100644 --- a/docs/working/git-sync-notes.md +++ b/docs/working/git-sync-notes.md @@ -16,12 +16,17 @@ user/config/plugins/git-sync.yaml ← NOT here Why this matters: -- **Server-only, not synced, not committed.** `user/env/` is outside the - content repo's tracked folders (`pages`/`config`/`accounts`/`themes`) and is - not one of git-sync's synced folders (`pages`/`config`/`themes`). So config - saved via Admin *on the server* never reaches Gitea or local. This is ideal - for the git-sync token (it stays server-only) but means **prod Admin config - edits silently diverge** — author durable config in the repo, not prod Admin. +- **⚠️ `user/env/` is NOT safe unless gitignored — it is NOT scoped out by the + `folders` setting.** An earlier version of this note claimed `user/env/` + "never reaches Gitea" because it is outside git-sync's synced folders. **That + is wrong and caused a live secret leak (2026-07-05).** git-sync's auto-commit + stages files *outside* the configured `folders`; on prod it pushed the whole + `user/env/intotheeast.com/config/` tree — JWT secret, CSRF salt, **and the + git-sync token + webhook secret** — to Gitea. The fix was to **gitignore + `/env/`** (commit `6e8eadb`). So: prod Admin config edits stay server-only + *only because `/env/` is now gitignored*, not because of folder scope. Author + durable config in the repo, not prod Admin. Full analysis: + `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md`. - **Look in both places.** When inspecting/toggling server config, check `user/config/plugins/.yaml` **and** `user/env//config/plugins/.yaml` (env wins). @@ -48,19 +53,25 @@ If you see the comma-string form on an older version, fix it by editing `git-sync.yaml` directly (at whichever path it lives — see above); do not re-save folders via the Admin UI on the buggy version. -## Per-install / secret files — must be gitignored (never synced) +## Per-install / secret files — must be gitignored (gitignore is the boundary) -git-sync syncs the `config/` folder, so any per-install or secret file tracked -there would get pushed to Gitea and pollute every environment. Keep these out -of the content repo (all in `user/.gitignore`): +git-sync's auto-commit stages **everything under `user/` that is not +gitignored** — the `folders` setting does *not* scope the commit add-set (a +2026-07-05 leak proved this by pushing `user/env/**`, outside the configured +folders). So `.gitignore` — not folder scope — is the only thing keeping a +per-install or secret file off Gitea. Keep all of these gitignored in +`user/.gitignore`: -| File | Why | +| Path | Why | |---|---| +| `env/` | **whole per-host env tree** — holds the live git-sync token, JWT secret, CSRF salt + all server-side Admin config. Gitignored + untracked 2026-07-05 (commit `6e8eadb`) after it leaked to Gitea. NOT safe on folder scope alone. | | `config/plugins/git-sync.yaml` | encrypted token; server-specific (also lives at env path on prod) | | `config/plugins/api-private.php` | API JWT secret | | `config/security.yaml` | Grav nonces/salts (legacy location) | | `config/versions.yaml` | per-install Grav schema-migration state — differs per env (dev 2.0.4, prod 2.0.7); Grav regenerates it. Untracked 2026-07-04. | -| `config/security-private.php` | **TODO:** committed salt secret; should be gitignored like `api-private.php` (deferred — untracking resets server sessions) | +| `config/security-private.php` | CSRF/nonce + admin rate-limit signing salt; gitignored + untracked 2026-07-05 (commit 2840018). Each env keeps its own; untracking regenerates prod's salt (one-time admin re-login). | + +> **Why a key inside a *tracked* config file (e.g. `popularity.salt` in `api.yaml`) can't just be stripped** — it regenerates at runtime and boomerangs back via git-sync's `git add -A`. See `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` for the full round-trippable-set model. ## git-sync config summary (prod, 2026-07-04)