Audit remediation and embedded web UI #3

Merged
kleb merged 61 commits from audit-and-web-ui into main 2026-07-19 21:08:58 +02:00
Owner

Summary

This branch bundles two entangled bodies of work against main:

  1. Embedded web UI (internal/web + web/) — an opt-in React management UI served by kbackupd over its own HTTP(S) listener, with an in-process gRPC bridge, setup-token auth, and committed web/dist embedded via go:embed. (Commits 2e03d91, d7a717e.)
  2. Integrity-focused audit remediation — 11 fixes from an adversarial audit whose guiding rule was: never report a backup as successful/verified, or data as restorable, unless we are actually sure. The audit fixes build on the web UI (e.g. the same-origin fix edits internal/web/middleware.go), so they ship together.

The durability core was re-confirmed sound and left unchanged: crash-safe packs → index → snapshot ordering with an upload barrier, unconditional post-backup verification that fails the run, atomic+fsync backend writes, and complete prune reachability for file and image snapshots.

Audit fixes (most severe first)

Sev Area Fix
CRITICAL ntfs A truncated $Bitmap (fragmented via $ATTRIBUTE_LIST, go-ntfs drops a continuation record) under-reported used blocks and captureVolume holed out real data behind a "verified" image backup. The coverage guard sat only in the (0, nil) branch, but go-ntfs's RangeReader signals truncation as (0, io.EOF), bypassing it. Now the io.EOF path is guarded → fail-safe to a full read.
HIGH restore/image OpenFileTarget lacked O_TRUNC while PreZeroed() returned true unconditionally, so restoring over a pre-existing file left stale bytes in every blockmap hole — corrupt, reported success. Added O_TRUNC.
HIGH prune Prune/forget physically deleted data on a rollback-suspected repo (the flag only guarded backups), turning a recoverable rollback into permanent loss. Both destructive paths now refuse with ErrRepoRolledBack.
HIGH backend/retry retry.List replayed a non-idempotent callback on a transient error → duplicate snapshot IDs poisoned the anti-rollback counter, which could refuse all future backups. List is now attempt-safe; snapshot.List dedups.
HIGH repo On a shared repo, openCache's additive reconciliation let a stale cache reference a peer-pruned pack; dedup + verify would both pass on a dangling reference. Now rebuilds when an ingested segment is gone from the backend.
MED capture/fstree ADS store failures were swallowed as warnings (silently missing stream); now fatal. Also O(n²)→O(1) parent lookup and an 8 MiB-per-file buffer avoided for small files (dedup-identical).
MED web Malformed Origin header caused a pre-auth nil-pointer panic in the same-origin check; guarded.
MED notify Shutdown-time notifications were dropped (send ctx descended from the cancelled baseCtx); now delivered on a background ctx with a bounded drain.
MED job Image summary now distinguishes re-verified new blobs from copy-forward blocks so "verified" isn't read as attesting the whole volume.
LOW repo Close no longer turns an advisory-lock-removal blip into a failed backup; made idempotent.
LOW prune/cache Corrected stale comments (image walk is wired; pack stats are recomputed, not trusted).

Testing

  • go build ./..., go vet ./... clean.
  • go test ./... — 52 packages, 0 failures.
  • Every fix ships a regression test; several (retry list, peer-prune rebuild, shutdown notify, verified-finish) were confirmed to fail without the fix.
  • No host-mutating Windows/VHD tests were run (synthetic/mock inputs only).

Deliberately out of scope (documented, not gaps)

  • Post-backup backend-verification of deduped blobs — the peer-prune data-loss window is fully closed by the cache-rebuild fix; backend-verifying every deduped blob would duplicate what periodic scrub / deep-verify already cover, at a per-backup round-trip cost.
  • Snapshot-consistency honesty was audited manually (require-snapshot hard-fails; auto fallback is surfaced; VSS shadow held for the whole backup) and found sound.
## Summary This branch bundles two entangled bodies of work against `main`: 1. **Embedded web UI** (`internal/web` + `web/`) — an opt-in React management UI served by `kbackupd` over its own HTTP(S) listener, with an in-process gRPC bridge, setup-token auth, and committed `web/dist` embedded via `go:embed`. (Commits `2e03d91`, `d7a717e`.) 2. **Integrity-focused audit remediation** — 11 fixes from an adversarial audit whose guiding rule was: *never report a backup as successful/verified, or data as restorable, unless we are actually sure.* The audit fixes build on the web UI (e.g. the same-origin fix edits `internal/web/middleware.go`), so they ship together. The durability core was re-confirmed sound and left unchanged: crash-safe `packs → index → snapshot` ordering with an upload barrier, **unconditional** post-backup verification that fails the run, atomic+fsync backend writes, and complete prune reachability for file *and* image snapshots. ## Audit fixes (most severe first) | Sev | Area | Fix | |-----|------|-----| | **CRITICAL** | `ntfs` | A truncated `$Bitmap` (fragmented via `$ATTRIBUTE_LIST`, go-ntfs drops a continuation record) under-reported used blocks and `captureVolume` holed out **real data** behind a "verified" image backup. The coverage guard sat only in the `(0, nil)` branch, but go-ntfs's `RangeReader` signals truncation as `(0, io.EOF)`, bypassing it. Now the `io.EOF` path is guarded → fail-safe to a full read. | | HIGH | `restore/image` | `OpenFileTarget` lacked `O_TRUNC` while `PreZeroed()` returned `true` unconditionally, so restoring over a pre-existing file left stale bytes in every blockmap hole — corrupt, reported success. Added `O_TRUNC`. | | HIGH | `prune` | Prune/forget physically deleted data on a rollback-suspected repo (the flag only guarded backups), turning a recoverable rollback into permanent loss. Both destructive paths now refuse with `ErrRepoRolledBack`. | | HIGH | `backend/retry` | `retry.List` replayed a non-idempotent callback on a transient error → duplicate snapshot IDs poisoned the anti-rollback counter, which could refuse **all** future backups. List is now attempt-safe; `snapshot.List` dedups. | | HIGH | `repo` | On a shared repo, `openCache`'s additive reconciliation let a stale cache reference a peer-pruned pack; dedup + verify would both pass on a dangling reference. Now rebuilds when an ingested segment is gone from the backend. | | MED | `capture/fstree` | ADS store failures were swallowed as warnings (silently missing stream); now fatal. Also O(n²)→O(1) parent lookup and an 8 MiB-per-file buffer avoided for small files (dedup-identical). | | MED | `web` | Malformed `Origin` header caused a pre-auth nil-pointer panic in the same-origin check; guarded. | | MED | `notify` | Shutdown-time notifications were dropped (send ctx descended from the cancelled `baseCtx`); now delivered on a background ctx with a bounded drain. | | MED | `job` | Image summary now distinguishes re-verified new blobs from copy-forward blocks so "verified" isn't read as attesting the whole volume. | | LOW | `repo` | `Close` no longer turns an advisory-lock-removal blip into a failed backup; made idempotent. | | LOW | `prune`/`cache` | Corrected stale comments (image walk is wired; pack stats are recomputed, not trusted). | ## Testing - `go build ./...`, `go vet ./...` clean. - `go test ./...` — 52 packages, 0 failures. - Every fix ships a regression test; several (retry list, peer-prune rebuild, shutdown notify, verified-finish) were confirmed to fail without the fix. - No host-mutating Windows/VHD tests were run (synthetic/mock inputs only). ## Deliberately out of scope (documented, not gaps) - **Post-backup backend-verification of deduped blobs** — the peer-prune data-loss window is fully closed by the cache-rebuild fix; backend-verifying every deduped blob would duplicate what periodic scrub / deep-verify already cover, at a per-backup round-trip cost. - Snapshot-consistency *honesty* was audited manually (require-snapshot hard-fails; auto fallback is surfaced; VSS shadow held for the whole backup) and found sound.
kleb added 13 commits 2026-07-18 16:27:16 +02:00
Part A -- audit: a multi-agent find/verify pass (7 dimension finders,
adversarial verification) surfaced 43 confirmed findings (1 critical, 19
high, 19 medium, 4 low); all are fixed here. Highlights:

- lock: repository locks now fail closed (create-then-verify, reject on
  conflict) instead of warn-and-proceed; authoritative cross-host leasing
  tracked in docs/audit-backlog.md.
- backend: local deletes + lazily-created dirs are fsynced; SFTP saves are
  atomic-or-fail with fsync failures propagated.
- job/progress: bounded queue-overlap admission, child-context cleanup,
  terminal-run eviction unblocked, settings/maintenance race closed, slow
  SSE subscribers disconnected with ResourceExhausted.
- crypto/restore/notify: tighter Argon2 caps + per-open KDF work budget;
  restore symlink escapes closed (O_NOFOLLOW / reparse-safe opens); webhook
  and shoutrrr errors redact secret-bearing URLs.
- Windows: CBT fails closed on fingerprint problems; USN parse failures
  force a full read; VSS/CBT accept image-job volume spellings; ADS +
  metadata survive long paths.
- Linux: dm-era state bound to metadata identity/geometry; LVM snapshot
  cleanup survives cancellation and whitespace mountpoints; setuid/setgid/
  sticky + xattr/ACL restore fixed; raw-image restores fsynced.
- service/config: RPC upserts enforce the same contract as YAML load; job
  delete rejected while a schedule references it; settings round-trips
  preserve non-proto fields; duplicate schedules rejected; overlay file
  gets the same Linux permission checks as the base config.
- tooling: 0600 packaged config, ProgramData ACL hardening, clean-room
  proto regen, deb/rpm version normalization, postinstall failure
  propagation, pinned goversioninfo, conservative systemd hardening.
- daemon log tightened to owner-only (0600/0700).
See docs/AUDIT.md for the full findings table.

Part B -- web UI: opt-in HTTP(S) listener embedded in kbackupd serving a
React SPA (go:embed) plus a JSON/SSE API that calls the existing gRPC
service implementations in-process (one behavior across both transports).

- config: root web{} block (base-file-owned); non-loopback binds require
  TLS or an explicit insecure_http acknowledgment.
- auth: first-run setup token written to an owner-only file; Argon2id
  verifier; in-memory sessions; CSRF synchronizer + scheme/host Origin
  checks on every mutation; per-IP login rate limiting with lockout;
  context-aware + globally-bounded KDF gate.
- API: full /api/v1 surface (runs/jobs/repos/snapshots/restores/schedules/
  maintenance/mounts/notifications/settings), SSE progress stream with
  session re-validation and shutdown-aware cancellation, lazy tree browse.
- frontend: React 19 + Vite + Tailwind 4 + shadcn/ui + TanStack Query +
  protobuf-ES types; management-parity pages; committed web/dist.
- packaging/docs: Build-Installer.ps1 and build-packages.sh rebuild the UI
  (Node prereq); README + config examples document setup, TLS policy, and
  password recovery.

Verified: go build/vet/test ./... green on Windows; GOOS=linux build/vet
for platform-only fixes; go test -race ./internal/web; frontend
typecheck/lint/test/build; a real-daemon HTTP e2e (setup -> login -> repo
create -> job run -> live SSE summary -> history).
Frontend quality tooling (ts-quality), staying on the existing npm + tsc
setup (TypeScript 7 stable is already the native compiler):

- knip: config focused on app code (ignores generated protobuf, vendored
  shadcn primitives, build output). Surfaced and removed real dead code
  left by the two-agent frontend split -- ~20 unused, stale-routed query
  hooks in src/api/queries.ts (the feature pages use their own local
  queries.ts), the unused placeholder-page, and several needlessly-exported
  internal symbols. knip now passes clean.
- Playwright: browser smoke (e2e/) that builds the bundle, serves it with
  vite preview, and asserts the auth gate renders the right page in
  Chromium against stubbed API responses -- closing the plan's browser-smoke
  gap. Vitest owns src/ unit tests; e2e runs via `npm run e2e`.
- oxfmt: format/format:check scripts added (available, not mass-applied to
  avoid churn on the existing tree).
- New `check` script chains typecheck + lint + knip + test.

Also fixes the setup page copy: it still told operators to find the setup
token in the daemon log, but the daemon now writes it to the owner-only
web-setup-token file (and logs only the path). Updated the on-screen
instructions and paths to match; rebuilt the embedded web/dist.

Verified: npm run check (typecheck/lint/knip/test) green; npm run e2e
(3 Playwright tests) green; go build of the web embed with the rebuilt dist.
UsedBlocks' coverage guard against a short/truncated $Bitmap lived only in
the (0, nil) read branch, but go-ntfs's RangeReader signals the end of its
resolved runs -- including a run list truncated by an unresolved
$ATTRIBUTE_LIST continuation -- as (0, io.EOF), which the loop's EOF break
took first, bypassing the guard. A truncated $Bitmap therefore under-
reported allocation and captureVolume holed out real data behind a
"verified" image backup.

Move the coverage check so it also validates the io.EOF termination: a
short read now returns an error, so capture falls back to FSRaw (every
block read fresh) rather than silently discarding data. Extract the read
loop into readBitmapUsed so it is unit-testable with a synthetic reader.
OpenFileTarget opened the target with O_CREATE|O_WRONLY (no O_TRUNC) then
Truncate(size), while fileTarget.PreZeroed() returned true unconditionally.
Truncate only zero-fills when it extends a file, so restoring over a path
that already held data (a re-restore to the same path, or a reused image
file) kept the old bytes in every blockmap hole -- corrupt exactly in the
zero regions -- while still reporting success.

Add O_TRUNC so the target starts empty and Truncate(size) yields a genuine
zero-reading sparse range, making PreZeroed()==true legitimate.
The anti-rollback flag was enforced only in NewWriteSession (refusing
backups). prune.Execute and prune.ApplyForget -- which physically delete
packs and index segments -- never checked it, so on a metadata-only
rollback a scheduled prune would sweep the still-present blobs of the
reverted-away snapshots, turning a recoverable rollback into permanent
loss.

Gate both destructive entry points on Repo.RollbackSuspected(), returning
ErrRepoRolledBack. Read-only Mark/Check/PreviewForget stay available.
retryBackend.List replayed the caller's callback from the start on a
retryable mid-listing error. snapshot.List appends to a slice in that
callback, so a transient network drop produced duplicate snapshot IDs,
which poisoned the anti-rollback generation hash/count and could trip a
false rollback that blocks all future backups.

Buffer each attempt's entries and forward them to the caller only after a
fully successful underlying listing, so a retried attempt never double-
delivers. Also dedup snapshot.List into a set as defense in depth, and
correct the retry doc's inaccurate idempotency claim.
openCache reconciles additively -- it ingests new index segments but never
removes anything. On a shared repository, after another host prunes a
segment (and its pack) off the backend, this host's cache still maps blobs
to the deleted pack. A later backup would dedup fresh content against a
missing blob (stored=false) and pass post-backup verify's cache.Has
reachability check, committing a snapshot with a silent dangling reference.

Detect an ingested-segment marker whose segment is gone from the backend
and RebuildCache from the surviving segments (self-healing). checkRollback
then re-adopts the generation via trust-on-first-use. Single-writer repos
never trigger it: a host's own prune clears its markers in the same run.
Three fixes interleaved in walk.go:

- Integrity: a repository/SaveBlob failure while storing a file's alternate
  data stream was downgraded to a warning and the stream dropped,
  contradicting processFile's fatal-on-store-error invariant and committing
  a snapshot with a silently missing stream. Store failures are now fatal;
  only an unreadable-stream open failure stays a warning.
- Perf: buildTree's per-entry findParentNode was a linear scan, making an
  incremental walk O(N*M) per directory. Build a name->node map once for
  O(1) lookups.
- Perf: storeStream allocated an 8 MiB FastCDC buffer per file. Small files
  (<= min chunk size) now store as a single blob without the chunker,
  byte-identical to the CDC single-chunk output (dedup-preserving).
WriteSession.Close folded a transient backend error from removing the
advisory defensive repo-lock object into a fatal return, so a fully
durable, published backup was reported as FAILED (and post-backup verify
skipped). WithPruneLock already treats the same removal as best-effort.

Log the advisory-lock removal error instead of returning it, and make
Close idempotent (a deferred second Close is now a no-op) so it no longer
double-releases.
mark.go still claimed image snapshots were unwalkable ("nil in M8, M10
doesn't exist yet") though WalkBlockMap is wired via DefaultConfig.
ingest.go warned pack-stat writes would need read-modify-write "once prune
exists" -- prune exists and safely recomputes stats from the live set, so
the stored zeros are harmless. Comment-only.
sameOrigin dereferenced url.Parse's result unconditionally in the scheme
comparison, but url.Parse returns a nil *url.URL on error. An
unauthenticated request with a malformed Origin (e.g. "http://[") panicked
per request -- recovered by net/http, but dropping the connection and
spamming the log. Only compare the scheme when the Origin parsed and is not
already rejected.
Shutdown cancels baseCtx before draining the notifier, so a notification
for a run interrupted by shutdown built its send context from an already-
cancelled ctx and failed instantly -- the "cancelled by shutdown" alert an
operator most wants was silently lost.

Derive the per-attempt send context from context.Background() with the send
timeout, keeping baseCtx only to short-circuit the retry backoff wait. Size
the service's notify drain timeout off the send timeout so drain cannot cut
a send short.
Changes interleaved in dispatcher.go:

- Stability: a shutdown notification's wg.Add happened after the run left
  Registry.Active(), so Drain could return in the gap and the alert was
  never delivered. A dispatcher-level notifyWG is now Add'd before
  run.finish and awaited by Drain.
- Refactor: extract the shared setStats -> verify -> finish -> summary ->
  notify tail of runFolderBackup/runImageBackup into finishVerifiedBackup,
  so the durability-critical verify/finish ordering can't drift between the
  two source types. Verify stays unconditional and still fails the run.
- Honesty: the image summary now distinguishes re-verified new blobs from
  copy-forward blocks (covered by periodic scrub), so "verified" is not
  read as attesting the whole volume.
- Refactor: extract the duplicated cancel-vs-fail mapping into
  terminalErrState (6 call sites, incl. maintenance.go).
Collaborator

kReview review

Verdict: 1 High, 3 Medium

High risk remains because repository lock objects still expire during operations lasting more than six hours, allowing conflicting destructive work to proceed. Backend listings are also fully buffered in memory before callbacks run.

High (1)

  • Refresh repository locks before their TTL expiresinternal/lock/repolock.go:99
    A backup, restore, or prune lasting beyond the six-hour DefaultTTL can lose its cross-process exclusion while still running, allowing another process to treat its lock as stale and begin conflicting work. The acquisition path writes and scans the lock once, then returns without any visible renewal mechanism; refresh the lock periodically before expiry until release.
    Suggested fix: Start a bounded renewal loop after successful acquisition that rewrites the same lock with a refreshed timestamp well before TTL expiry, stop it during release, and fail the active operation if renewal cannot be confirmed.

Medium (3)

  • Avoid buffering the entire backend listinginternal/backend/retry/retry.go:326
    Repositories with very large object listings can exhaust daemon memory because every backend.FileInfo is accumulated before the first caller callback runs. This also delays streaming consumers until enumeration completes; preserve attempt safety using bounded paging, a temporary disk-backed spool, or backend-specific continuation support rather than an unbounded slice.
    Suggested fix: Replace the unbounded in-memory slice with a bounded or disk-backed attempt buffer, or extend the backend listing contract with stable pagination so completed pages can be forwarded without replaying earlier callbacks.

  • Preserve Linux target ownership during replacementinternal/restore/image/target_linux.go:55
    Restoring over an image owned by another UID/GID replaces it with a fresh inode owned by the daemon, potentially changing who can manage or access the file. The replacement copies only permission bits with Fchmod; preserve the existing st.Uid and st.Gid with Fchown before renaming the staged file into place.
    Suggested fix: Capture the existing regular file's UID and GID from Fstatat and apply them to the temporary file with Fchown before Renameat, handling privilege failures explicitly.

  • Preserve the full Windows security descriptorinternal/restore/image/target_windows.go:274
    Replacing a Windows image target creates a new file owned by the restoring account and applies only its DACL; owner, primary group, and auditing metadata are discarded or inherited. For a target owned by another principal, this changes its security descriptor even though the restore otherwise claims to preserve permissions.
    Suggested fix: Capture the existing file's owner, group, DACL and applicable control flags, then apply them to the staged file before the rename; preserve SACL metadata when the process has the required privilege.

Excluded as generated or vendored (not reviewed):

17 excluded files
  • build/linux/README.md
  • build/linux/build-packages.sh
  • build/linux/nfpm.yaml
  • build/linux/scripts/postinstall.sh
  • build/linux/scripts/postremove.sh
  • build/linux/scripts/preremove.sh
  • build/linux/systemd/kbackupd.service
  • build/windows/Build-Installer.ps1
  • build/windows/installer/README.md
  • build/windows/installer/harden-data-acl.ps1
  • build/windows/installer/kbackup.exe.manifest
  • build/windows/installer/kbackup.iss
  • build/windows/installer/versioninfo.json
  • web/dist/assets/index-BDMIidHk.js
  • web/dist/assets/index-BlrQG5F3.css
  • web/dist/index.html
  • web/package-lock.json

Reviewed by kReview at cfc1ad8f50. This comment is conservative and based only on the PR diff and metadata.

Est. cost ~$115.78 total (20.6M in / 563.1k out) · this run ~$2.61 (469.8k in / 10.3k out) / gpt-5.6-sol.

<!-- codex-forgejo-review --> <!-- codex-forgejo-review-head:cfc1ad8f50475de1f013acf803e794a419c65ea8 --> ## kReview review **Verdict:** 1 High, 3 Medium High risk remains because repository lock objects still expire during operations lasting more than six hours, allowing conflicting destructive work to proceed. Backend listings are also fully buffered in memory before callbacks run. ### High (1) - **Refresh repository locks before their TTL expires** — [`internal/lock/repolock.go:99`](https://git.kleb.sh/kleb/kBackup/src/commit/cfc1ad8f50475de1f013acf803e794a419c65ea8/internal/lock/repolock.go#L99) A backup, restore, or prune lasting beyond the six-hour `DefaultTTL` can lose its cross-process exclusion while still running, allowing another process to treat its lock as stale and begin conflicting work. The acquisition path writes and scans the lock once, then returns without any visible renewal mechanism; refresh the lock periodically before expiry until release. Suggested fix: Start a bounded renewal loop after successful acquisition that rewrites the same lock with a refreshed timestamp well before TTL expiry, stop it during release, and fail the active operation if renewal cannot be confirmed. ### Medium (3) - **Avoid buffering the entire backend listing** — [`internal/backend/retry/retry.go:326`](https://git.kleb.sh/kleb/kBackup/src/commit/cfc1ad8f50475de1f013acf803e794a419c65ea8/internal/backend/retry/retry.go#L326) Repositories with very large object listings can exhaust daemon memory because every `backend.FileInfo` is accumulated before the first caller callback runs. This also delays streaming consumers until enumeration completes; preserve attempt safety using bounded paging, a temporary disk-backed spool, or backend-specific continuation support rather than an unbounded slice. Suggested fix: Replace the unbounded in-memory slice with a bounded or disk-backed attempt buffer, or extend the backend listing contract with stable pagination so completed pages can be forwarded without replaying earlier callbacks. - **Preserve Linux target ownership during replacement** — [`internal/restore/image/target_linux.go:55`](https://git.kleb.sh/kleb/kBackup/src/commit/cfc1ad8f50475de1f013acf803e794a419c65ea8/internal/restore/image/target_linux.go#L55) Restoring over an image owned by another UID/GID replaces it with a fresh inode owned by the daemon, potentially changing who can manage or access the file. The replacement copies only permission bits with `Fchmod`; preserve the existing `st.Uid` and `st.Gid` with `Fchown` before renaming the staged file into place. Suggested fix: Capture the existing regular file's UID and GID from `Fstatat` and apply them to the temporary file with `Fchown` before `Renameat`, handling privilege failures explicitly. - **Preserve the full Windows security descriptor** — [`internal/restore/image/target_windows.go:274`](https://git.kleb.sh/kleb/kBackup/src/commit/cfc1ad8f50475de1f013acf803e794a419c65ea8/internal/restore/image/target_windows.go#L274) Replacing a Windows image target creates a new file owned by the restoring account and applies only its DACL; owner, primary group, and auditing metadata are discarded or inherited. For a target owned by another principal, this changes its security descriptor even though the restore otherwise claims to preserve permissions. Suggested fix: Capture the existing file's owner, group, DACL and applicable control flags, then apply them to the staged file before the rename; preserve SACL metadata when the process has the required privilege. _Excluded as generated or vendored (not reviewed):_ <details> <summary>17 excluded files</summary> - `build/linux/README.md` - `build/linux/build-packages.sh` - `build/linux/nfpm.yaml` - `build/linux/scripts/postinstall.sh` - `build/linux/scripts/postremove.sh` - `build/linux/scripts/preremove.sh` - `build/linux/systemd/kbackupd.service` - `build/windows/Build-Installer.ps1` - `build/windows/installer/README.md` - `build/windows/installer/harden-data-acl.ps1` - `build/windows/installer/kbackup.exe.manifest` - `build/windows/installer/kbackup.iss` - `build/windows/installer/versioninfo.json` - `web/dist/assets/index-BDMIidHk.js` - `web/dist/assets/index-BlrQG5F3.css` - `web/dist/index.html` - `web/package-lock.json` </details> _Reviewed by kReview at `cfc1ad8f50`. This comment is conservative and based only on the PR diff and metadata._ _Est. cost ~$115.78 total (20.6M in / 563.1k out) · this run ~$2.61 (469.8k in / 10.3k out) / gpt-5.6-sol._ <!-- codex-forgejo-review-state:eyJoZWFkU2hhIjoiY2ZjMWFkOGY1MDQ3NWRlMWYwMTNhY2Y4MDNlNzk0YTQxOWM2NWVhOCIsInN1bW1hcnkiOiJIaWdoIHJpc2sgcmVtYWlucyBiZWNhdXNlIHJlcG9zaXRvcnkgbG9jayBvYmplY3RzIHN0aWxsIGV4cGlyZSBkdXJpbmcgb3BlcmF0aW9ucyBsYXN0aW5nIG1vcmUgdGhhbiBzaXggaG91cnMsIGFsbG93aW5nIGNvbmZsaWN0aW5nIGRlc3RydWN0aXZlIHdvcmsgdG8gcHJvY2VlZC4gQmFja2VuZCBsaXN0aW5ncyBhcmUgYWxzbyBmdWxseSBidWZmZXJlZCBpbiBtZW1vcnkgYmVmb3JlIGNhbGxiYWNrcyBydW4uIiwiZmluZGluZ3MiOlt7InNldmVyaXR5IjoiaGlnaCIsInRpdGxlIjoiUmVmcmVzaCByZXBvc2l0b3J5IGxvY2tzIGJlZm9yZSB0aGVpciBUVEwgZXhwaXJlcyIsImZpbGUiOiJpbnRlcm5hbC9sb2NrL3JlcG9sb2NrLmdvIiwibGluZSI6OTksInF1b3RlZF9zbmlwcGV0IjoiY29uZmxpY3QsIGVyciA6PSBybC5zY2FuRm9yZWlnbihjdHgsIGlkLCBtb2RlLCB3YXJuKSJ9LHsic2V2ZXJpdHkiOiJtZWRpdW0iLCJ0aXRsZSI6IkF2b2lkIGJ1ZmZlcmluZyB0aGUgZW50aXJlIGJhY2tlbmQgbGlzdGluZyIsImZpbGUiOiJpbnRlcm5hbC9iYWNrZW5kL3JldHJ5L3JldHJ5LmdvIiwibGluZSI6MzI2LCJxdW90ZWRfc25pcHBldCI6InZhciBidWZmZXJlZCBbXWJhY2tlbmQuRmlsZUluZm8ifSx7InNldmVyaXR5IjoibWVkaXVtIiwidGl0bGUiOiJQcmVzZXJ2ZSBMaW51eCB0YXJnZXQgb3duZXJzaGlwIGR1cmluZyByZXBsYWNlbWVudCIsImZpbGUiOiJpbnRlcm5hbC9yZXN0b3JlL2ltYWdlL3RhcmdldF9saW51eC5nbyIsImxpbmUiOjU1LCJxdW90ZWRfc25pcHBldCI6ImlmIGVyciA6PSB1bml4LkZjaG1vZChmZCwgdWludDMyKHByZXNlcnZlTW9kZSkpOyBlcnIgIT0gbmlsIHsifSx7InNldmVyaXR5IjoibWVkaXVtIiwidGl0bGUiOiJQcmVzZXJ2ZSB0aGUgZnVsbCBXaW5kb3dzIHNlY3VyaXR5IGRlc2NyaXB0b3IiLCJmaWxlIjoiaW50ZXJuYWwvcmVzdG9yZS9pbWFnZS90YXJnZXRfd2luZG93cy5nbyIsImxpbmUiOjI3NCwicXVvdGVkX3NuaXBwZXQiOiJpZiBlcnIgOj0gd2luZG93cy5TZXRTZWN1cml0eUluZm8od2luZG93cy5IYW5kbGUoZi5GZCgpKSwgd2luZG93cy5TRV9GSUxFX09CSkVDVCwgaW5mbywgbmlsLCBuaWwsIGRhY2wsIG5pbCk7IGVyciAhPSBuaWwgeyJ9XSwiY3VtdWxhdGl2ZUNvc3QiOnsidXNkIjoxMTUuNzc3MzY5MDAwMDAwMDIsImlucHV0VG9rZW5zIjoyMDU4NjUyNywib3V0cHV0VG9rZW5zIjo1NjMwNTcsIm1vZGVsIjoiZ3B0LTUuNi1zb2wifX0= -->
Fixes four findings from the kReview pass:

- HIGH (repo/repo.go): the peer-prune cache reconciliation ran inside
  openCache, before checkRollback, and RebuildCache wipes the anti-rollback
  anchor. A genuine backend rollback that removed a newer index segment
  reached the same missing-segment path, so the rebuild wiped the anchor and
  checkRollback then adopted the rolled-back generation via trust-on-first-use,
  disabling rollback detection. openCache now only REPORTS a missing segment;
  Open rebuilds after checkRollback, skips it when a rollback is suspected, and
  preserves the anchor across the rebuild. Regression test added.

- MEDIUM (backend/retry/retry.go): List forwarded buffered items to the
  caller's fn inside r.do, so a retryable error from fn re-ran the whole
  listing and re-delivered earlier items, breaking the at-most-once contract.
  fn is now invoked outside r.do; only the enumeration is retried. Regression
  test added.

- MEDIUM (repo/write.go): Close removed the advisory repo-lock object on the
  caller's context, which may already be cancelled by shutdown -- stranding the
  lock until its TTL and blocking conflicting Backup/Prune until then. Close now
  removes it on a fresh, short-bounded context so cleanup runs regardless of the
  run's cancellation.

- MEDIUM (web/auth.go): concurrent password changes captured the same state,
  both verified, and both replaced the verifier with the file write and
  in-memory assignment able to land in different orders. A dedicated changeMu
  now serializes the capture -> ChangePassword -> assign critical section.
Handles the reviewer's second-pass findings (validated against the code;
one suggested fix was rejected as unsafe -- see below).

Integrity / security:
- crypto/kdf.go: tighten the per-attempt Argon2 caps a repository-controlled
  keyfile may request (1 GiB/12/16 -> 256 MiB/6/8), bounding a single
  pre-auth unlock's allocation. Decouple repo's cumulative keyfile-unlock
  budget from the per-attempt cap (max of "16 default keyfiles" and "one
  max-cost attempt") so tightening the caps never rejects a legitimate
  multi-keyfile repo.
- restore/files: eliminate the intermediate-symlink TOCTOU on Linux -- every
  restore create/open now resolves the target's parent via
  openat2(RESOLVE_NO_SYMLINKS) (fallback: component-by-component
  openat(O_NOFOLLOW)) and acts relative to that verified dirfd
  (mkdirat/openat/symlinkat), so no operation re-resolves an
  attacker-controlled parent path. Behavior otherwise preserved; the residual
  path-based pre-checks in node.go can now only cause a spurious restore
  failure, never a misdirected write.
  NOTE: the Linux traversal is COMPILE-VERIFIED ONLY (built and vetted under
  GOOS=linux); its runtime behavior is UNVERIFIED (no Linux host here) and
  must be exercised on a real Linux kernel before merge.
- restore/files/open_windows.go: normalize the restore file path to
  extended-length \?\ form (mirroring fsmeta's win32Path), restoring
  >MAX_PATH support; device/VSS namespace paths pass through unchanged.
- web/ratelimit.go: gate the progressive login lockout on tlsActive, so a
  TLS-terminating reverse proxy's shared RemoteAddr can no longer let one
  client trigger a global admin lockout; the token bucket + global KDF
  admission cap still bound guessing when behind a proxy.
- repo/write.go: bounded, fresh-context retries when removing the advisory
  lock object (already-gone counts as success), shrinking the window a
  transient backend failure could strand it.

Correctness / UX:
- web notification detail: add GET /api/v1/notifications/{name} returning a
  REDACTED ChannelConfigSpec (name+type only, never the secret URL/params),
  so the edit dialog loads without leaking secrets. (The reviewer's suggested
  "return the full spec" would have leaked webhook secrets that ListChannels
  deliberately redacts -- rejected in favor of the redacted variant.)
- web repo edit form: make the repository name immutable in edit mode and
  always submit the original name, so an edit no longer orphans the config.
- web/auth_store.go: count password length in runes, not bytes.
- web format.ts: guard timestamps against JS Date's actual +/-8.64e15 range
  to avoid a RangeError.
- cmd/kbackupd/logfile.go: chmod existing log dir/file to 0700/0600 on Unix
  so upgraded installs don't keep world-readable logs (no-op on Windows).

web/dist rebuilt from the two source changes (typecheck/lint/test/build/
verify:dist all pass).
- repo: restore the anti-rollback generation anchor on every exit path of
  rebuildCachePreservingAnchor via defer. RebuildCache wipes the anchor as its
  first step, so a rebuild that failed partway left it permanently erased,
  silently downgrading the next Open's rollback check to trust-on-first-use.
- job: only wait on notifyWG in Drain once the active-run loop has actually
  observed Registry.Active() empty. A drain that timed out with runs still
  active could race a run's notifyWG.Add(1) against Wait while the counter sat
  at zero (the sync.WaitGroup misuse the race detector flags).
- web: fsync the auth-state parent directory after the rename so a first-time
  setup or password change is durable; a crash could otherwise lose the
  directory-entry update on ext4/xfs and revert the admin credential.
- web: re-derive the restore-files scope from the current subpath each time the
  dialog opens. The one-time useState initializer captured only the initial
  (empty) subpath, so opening the dialog after navigating into a folder still
  defaulted to Whole snapshot -- restoring/overwriting more than selected.
- web (HIGH): the retention-forget filter stays editable after a preview while
  apply re-reads the live jobName, so an operator could preview one job, change
  the filter, and confirm a forget that permanently deletes a different snapshot
  set than the table shown. Pin the previewed filter; hide the apply button and
  warn to re-preview when the live filter diverges, and hard-guard the apply
  path so it can never run against a filter other than the previewed one.
- web: reset the whole-image-restore target/confirmation when the dialog closes.
  The component stays mounted, so a matching confirmation persisted and reopening
  left the destructive Overwrite button enabled -- a stray click could start a
  whole-volume overwrite without re-typing the confirmation.
- web: adopt the new auth verifier once its rename commits and downgrade a
  post-rename parent-dir fsync failure to a logged warning. The prior fatal
  fsync returned an error after the rename had already replaced the on-disk
  verifier, leaving the live in-memory state stale -- a restart would then
  silently switch which password is accepted. Reporting the new state keeps
  memory and disk in agreement.
The repo prop is driven by the route param and ForgetSection is not remounted
when it changes (react-router reuses the page instance across param-only
changes), so a preview taken for one repository stays on screen after the
operator switches repos while apply re-reads the live repo -- permanently
forgetting snapshots in a different repository than the one shown. Pin the
previewed repository alongside the previewed filter, mark the preview stale
(hiding the apply button and prompting a re-preview) when either diverges, and
hard-guard the apply path so it can never target a repository other than the
one previewed.
write.go / capture: a durable, verified backup no longer silently swallows a
failure to remove the defensive repo-lock object. Close still does not fail the
run for it (the backup is durable and the lock self-heals via TTL), but it now
records the error, and fstree/image Backup fold it into the run-level
Result.Warnings so the run reports Warning -- a stranded lock blocks the next
Backup/Prune on the repository until its TTL lapses, which the operator should
see. Not added to the persisted manifest stats.Warnings (already written).

ratelimit.go: disable the per-IP token bucket (not just the lockout) whenever
the daemon does not terminate TLS itself. Behind a reverse proxy every request
shares the proxy's RemoteAddr, so a shared bucket lets one unauthenticated
client 429 every administrator -- the same monopolization the lockout gating
already guarded against. In that mode admission is governed solely by the global
KDF cap in auth.go, which bounds guessing cost without per-client state an
attacker can monopolize; restoring per-client limiting behind a proxy would need
a trusted-proxy config (deliberately out of scope). Tests updated: the HTTP-level
throttling test now runs against a TLS-mode limiter (the mode where per-IP
limiting applies), and the proxy-mode limiter test asserts the bucket no longer
denies.
The retry wrapper's List buffers every FileInfo before forwarding to fn, so it
never double-delivers across a retried enumeration -- but that is O(n) memory in
object count (~100MB for a multi-TB S3 repo's pack listing). Add an opt-in
streaming path for the callers whose fn is already idempotent under duplicate
delivery, so they no longer pay for the buffer.

- backend: add the optional StreamingLister interface (ListStream) plus a
  backend.ListStreaming helper that uses it when available and falls back to the
  buffering List otherwise. Its doc spells out the idempotency contract: a
  retried (restarted) enumeration may deliver an object more than once.
- backend/retry: implement ListStream -- stream fn inside r.do, wrapping any fn
  error in a non-unwrappable listFnError sentinel so isRetryable treats it as
  permanent (an fn error must never re-list) and unwrapping it back to the
  original for the caller. Two tests: an idempotent set-caller converges despite
  page-1 re-delivery on retry; a retryable fn error does not re-list.
- Migrate the memory-critical, already-idempotent listers to ListStreaming:
  repo openCache + RebuildCache (index, keyed puts), repo/prune CheckStructure/
  CheckFull/CheckDeep pack listings (name-keyed set inserts), and scrub's pack
  listing (adding a per-shard seen-set, since it appends to a slice).
- Deliberately left on the buffering List: snapshot.List (needs its own set;
  anti-rollback path), unlockAny (non-idempotent unlock-budget counters),
  migrate, CheckStructure's index count, prune listIndexSegmentIDs, scanForeign
  -- each either non-idempotent or too small to matter.
Extends the dirfd-relative create path (Agent A's earlier work) to the
inspect/remove operations node.go performs before writing, and adds the missing
Windows parent-traversal protection -- closing the two remaining restore-safety
Highs where a local attacker swapping an already-verified ancestor directory for
a symlink/junction could redirect a privileged restore write, or delete a file,
outside the selected target tree.

- node.go: prepareForDir, verifyRealDir, removeExistingSymlink, checkOverwrite,
  and restoreSymlink's inline replace now go through new lstatAt/removeAt
  primitives instead of path-based os.Lstat/os.Remove. prepareRestoreRoot keeps
  an ordinary Lstat for the caller-chosen root (its ancestors are above the tree
  and may legitimately cross symlinked mounts -- not the attack surface).
- open_linux.go: lstatAt via Fstatat(AT_SYMLINK_NOFOLLOW) and removeAt via
  Unlinkat, both relative to openParentNoSymlink's symlink-refusing parent fd,
  so an ancestor swap fails the op rather than redirecting it.
- open_windows.go: replace the parent-following CreateFile path (which
  FILE_FLAG_OPEN_REPARSE_POINT only protected at the leaf) with single
  NtCreateFile calls carrying OBJ_DONT_REPARSE -- the object manager then refuses
  to traverse ANY reparse point in the path (the Windows analogue of Linux
  openat2 RESOLVE_NO_SYMLINKS, confirmed empirically). lstatAt/removeAt add
  FILE_OPEN_REPARSE_POINT so a reparse-point leaf is still inspectable/removable
  while intermediate junctions stay refused; createSymlinkForRestore builds the
  symlink handle-relative via FSCTL_SET_REPARSE_POINT instead of os.Symlink.
- Tests: Windows junction-ancestor-swap refusal for every primitive plus leaf
  and regression coverage (run on this host); the mirror Linux symlink-ancestor
  tests are compiled here and run on a real kernel (e.g. Incus). Full suite green
  on Windows; native + GOOS=linux + GOOS=windows build/vet clean.
Reverts 63bc6cd. The buffering retry List resets its buffer each retry attempt
and forwards only the final, complete enumeration -- so its callback sees the
backend listing as a single consistent snapshot. ListStream cannot preserve
that: it retries a transient mid-listing failure by RESTARTING the enumeration,
so the caller sees the union of the failed partial attempt plus the successful
one.

That union breaks every caller I migrated, which all compute a set difference
against the current listing rather than merely tolerating a duplicate within one
listing:
- openCache's stale-segment detection (ingested - backendSegs) gets a false
  negative if a segment listed in a failed attempt was pruned before the retry,
  so a peer's prune is missed and the cache keeps dangling blob->pack mappings --
  defeating the anti-rollback/stale-cache protection (a HIGH data-integrity
  regression flagged in review).
- RebuildCache would ingest a segment from a failed attempt that is no longer on
  the backend; CheckStructure/CheckFull/CheckDeep/scrub pack sets would likewise
  mask a concurrently-removed pack.

The caller audit that motivated Option B asked the wrong question (idempotent to
duplicates within a listing) instead of the real one (needs an exact snapshot).
The buffering List's memory cost is the price of that snapshot consistency; a
real memory fix needs page-level retry (a backend-interface change), not pushing
union semantics onto callers.
symlinkReparseBuffer pasted a UNC target (\server\share\...) verbatim after
the \??\ prefix, producing the malformed substitute name \??\server\share.
Emit the \??\UNC\server\share\... form Windows expects for a UNC symlink, as
os.Symlink does. Adds a unit test decoding the buffer's substitute name and
relative flag for drive, UNC, and relative targets.
Adds web.trusted_proxies (IP/CIDR list). Only when a request's immediate peer is
a configured trusted proxy does the daemon believe that request's forwarded
headers -- recovering the real client IP from X-Forwarded-For (walking it from
the right, skipping trusted hops, so a client-seeded spoof to the left is never
selected) and the scheme from X-Forwarded-Proto.

- ratelimit: per-IP login throttling now applies whenever the client is
  identifiable -- direct TLS OR behind a configured trusted proxy (keyed on the
  resolved client IP) -- not only under direct TLS. With no trusted proxy on a
  plaintext listener the RemoteAddr may be a shared proxy address, so throttling
  stays disabled there (global KDF cap only) to avoid one client locking out
  everyone. Field renamed tlsActive -> perClient to match.
- auth: session cookie Secure is set when the external connection is HTTPS --
  direct TLS, or a trusted proxy that reported X-Forwarded-Proto: https -- so
  cookies behind an HTTPS-terminating proxy are no longer sent in the clear,
  without breaking a genuinely plaintext local deployment.
- server: CSP style-src now allows 'unsafe-inline' (the SPA sets inline style
  attributes at runtime for positioning/animation; script-src stays 'self').
- proxy.go: proxyResolver with spoofing-resistant clientIP + isSecure, config
  validation of trusted_proxies, and thorough tests (spoof prefix ignored,
  untrusted peer can't forge, multi-hop chains, scheme handling).
- snapshots-page: reset the restore-selection subpath whenever the viewed
  snapshot (repo/snapshotId) changes. react-router reuses the component instance
  across param-only navigation, so without this a selection made on one snapshot
  could apply a restore to a different one.
- repository-form: clear the SFTP password field when the auth type switches to
  private-key, so a previously entered password is not submitted alongside a key.
- schedules-page: fix nextCronFire for leap-day schedules. Its search window was
  one year (527040 minutes), which never contains Feb 29 -- a valid "... 29 2 *"
  schedule showed as Invalid/never-fires. Widen the window to ~9 years (longer
  than the worst-case gap between leap years, since century years not divisible
  by 400 skip one). Adds a unit test (accepts Feb-29 cron, finds 2028, skips the
  non-leap century 2100).
Data-safety: an apply could permanently forget a different set of
snapshots than the operator previewed and confirmed (e.g. a scheduled
backup created a new snapshot between preview and apply). ApplyForget now
accepts the previewed forget IDs and refuses (ErrForgetSetChanged ->
FAILED_PRECONDITION) without deleting anything if the recomputed set
differs. The web apply pins the previewed decisions; the automated
space-reclaim caller stays unbound.

- api: add ApplyForgetRequest.expected_forget_ids (regenerated Go/TS)
- prune: ApplyForget expected-set guard + order-independent set compare
- service: map ErrForgetSetChanged to FAILED_PRECONDITION
- web: send expectedForgetIds from the previewed decisions; refresh on reject
- web: changePassword sends {currentPassword,newPassword}; reset tree
  expansion/path when the repo or snapshot changes
- web/auth: close a TOCTOU where a login racing a password change could
  mint a session after revokeAll ran, letting an old-password holder
  survive the change. changeMu is now an RWMutex: handleLogin holds the
  read lock from verifier capture through issueSession; the change keeps
  the write lock through revokeAll. (-race clean)
- repos form: switching SFTP auth type now also sets the clear flag for the
  now-inactive secret, so an edit cannot leave the old password/passphrase
  persisted alongside the new key -- the config contract is "exactly one of
  password or private_key_path".
- schedules: rewrite nextCronFire to advance field by field (skip whole
  non-matching months/days/hours) instead of scanning up to ~4.7M minutes
  synchronously; a valid-but-impossible expression (e.g. "0 0 31 2 *") now
  settles in a few hundred iterations rather than freezing the UI on render.
- schedules: reject malformed cron ranges/steps with extra separators
  ("1-2-3", "1/2/3", "1-", "-5") instead of silently truncating them.
- tests lock the new cron walk (impossible-date null, daily/same-day fire,
  dom/dow OR semantics, step ranges) and the tightened validation.
- repos form: reset the now-active method's clear flag when switching SFTP
  auth, so toggling back to a method no longer strands a stale clear flag
  that would wipe the credential the user is switching to.
- schedules: a valid expression that never fires (e.g. "0 0 31 2 *") now
  reads "No upcoming fire" instead of the misleading "Invalid cron"; only a
  genuine parse failure shows "Invalid cron".
- notifications: require the URL when an edit changes the channel type
  (a type change preserves no stored value), and only promise "leave blank
  to keep current" for same-type edits.
Empty params on a same-type channel edit meant "keep stored", so there was
no way to remove every parameter from a channel (stored params are hidden on
read, and an empty map is ambiguous with "unchanged"). Add an explicit
clear_params flag to ChannelConfigSpec: when set, empty params mean "remove
all stored params" instead of keep.

- proto/gen: ChannelConfigSpec.clear_params (Go + TS regenerated)
- service: mergeChannelEdit honors clearParams; test covers the clear path
- web: a "Clear all stored parameters" control on edit sends the flag when no
  parameters are listed; adding any parameter still replaces them all
- restore (HIGH): no-overwrite file writes now open O_EXCL / FILE_CREATE
  instead of O_TRUNC / FILE_OVERWRITE_IF, so a file raced into place after
  checkOverwrite is refused (ErrExists) rather than silently truncated. The
  no-overwrite guarantee is now atomic at the syscall, on both platforms.
- prune: ApplyForget takes an explicit bound flag rather than inferring
  binding from a non-empty ID list, so an all-keep preview is bound too (a
  snapshot that became forgettable between preview and apply is refused). The
  gRPC apply is always bound; the automated space-reclaim caller is not.
- web/auth: login throttling is always active, keyed on the resolved client
  IP. A direct plaintext client has a usable RemoteAddr and must not be
  exempt; the shared-address case is handled by declaring a trusted proxy.
- repos form: a non-empty replacement secret now unsets its clear flag, so an
  edit never ships a contradictory clear=true alongside a new credential.
- tests: no-overwrite refusal, empty-bound forget refusal, always-on
  throttling, and the bound gRPC apply path.
- restore (follow-on to the O_EXCL fix): removeExistingSymlink and the symlink
  restore's removeAt now run only when Overwrite is set. In no-overwrite mode
  checkOverwrite already rejects an entry present at check time, and the
  exclusive open (O_EXCL / FILE_CREATE / Symlinkat) rejects one raced in
  afterward -- removing it first would let the create succeed and silently
  replace it, defeating the no-overwrite guarantee.
- web restore (HIGH): the whole-image restore dialog now clears targetVolume
  and its confirmation on every close path via one close() helper (Cancel,
  successful submit, and onOpenChange). Cancel/submit call setOpen(false)
  directly, which does not run onOpenChange, so a cancelled destructive restore
  no longer stays type-to-confirm-armed and re-triggerable with one click.
- api: add ApplyForgetRequest.bind_expected_forget_ids so an all-keep preview
  (empty forget set) is bound explicitly -- proto3 repeated fields have no
  presence, so an empty list alone cannot distinguish "confirmed no deletions"
  from "apply without preview". The web sets it; the service binds on the flag
  or a non-empty set. (Go/TS regenerated.)
Creating an SFTP repository with password auth and a blank password saved an
unusable repo: for a new repo there is no stored secret to fall back on, so the
daemon rejects the connection as having no authentication method. The form
already required a private-key path for key auth but nothing for password auth.
The schema is now built per new-vs-edit (makeSchema): a new password-auth SFTP
repo must carry a password, while an edit keeps the blank-means-unchanged
semantics for the stored secret.
- fsmeta (Windows): win32Path now filepath.Clean-normalizes a long path before
  adding the \?\ extended-length prefix. The prefix disables Win32
  normalization, so a path with forward slashes or . / .. components was
  otherwise turned into one the OS cannot open. NT/device/GLOBALROOT paths stay
  untouched (checked before normalizing).
- repos form: the SFTP password requirement is now driven by whether a stored
  password exists (hasStoredPassword), not just new-vs-edit. Switching an
  existing key-auth repo to password auth on edit has no stored password to fall
  back on, so a blank password would save an unusable repo -- it is now required
  there too, while an edit of a password-auth repo still keeps blank-means-unchanged.
Restore hardening previously resolved every descendant create/open/stat/delete
from the filesystem root with RESOLVE_NO_SYMLINKS (Linux) / OBJ_DONT_REPARSE
from the object-manager root (Windows), which refused symlinked/junctioned
ancestors ABOVE the restore root too -- breaking restores into a symlinked
destination mount, the very case the path-based root was meant to support.

The root is now opened once (openRestoreRoot), path-based, following symlinked
ancestors to the caller-chosen destination. Every descendant operation resolves
its target RELATIVE to that root descriptor:
- Linux: openat2(rootfd, rel, RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS), with a
  per-component O_NOFOLLOW walk from the root fd as the pre-5.6 fallback.
- Windows: NtCreateFile with the root handle as OBJECT_ATTRIBUTES.RootDirectory
  and OBJ_DONT_REPARSE.

Symlinks/reparse points BELOW the root (the attack surface the restore itself
creates or descends) are still refused, closing the same TOCTOU window; those
ABOVE the root are allowed. The open/stat/delete primitives are now methods on
restoreRoot held by the restorer; O_EXCL/FILE_CREATE no-overwrite and leaf
handling are unchanged.

Validated on real OSes: Linux (kernel 6.12, openat2 path) and Windows both pass
the below-root-refused and above-root-allowed tests plus the end-to-end restore
suite.
- restore (close a TOCTOU introduced by anchoring at the root fd): openRestoreRoot
  no longer verifies the root and then opens it as a separate path-based step.
  It opens the root's parent following symlinked/junctioned ancestors, then opens
  the root's own final component relative to that parent without following a leaf
  reparse (Linux O_NOFOLLOW; Windows OBJ_DONT_REPARSE against the parent handle).
  A concurrent swap of the root itself to a symlink between the verify and the
  open can no longer redirect the root fd. Symlinked destination mounts still
  work (ancestors are followed); a leaf symlink was already rejected by
  prepareRestoreRoot, now atomically. Validated on Linux (kernel 6.12) and Windows.
- repos form: a password is now required whenever no usable stored password will
  remain -- new repo, an edit switching to password auth, OR clearing the stored
  password without a replacement. Previously clearing the active password while
  staying on password auth could save a repo with no credential.
- prune (finding: bind the check inside the destructive lock): ApplyForget now
  computes the preview, checks the bound expected-set, and deletes all inside a
  single WithPruneLock scope, so a backup publishing a snapshot between the check
  and the deletion can no longer let a bound apply proceed on a stale set. Because
  ModePrune conflicts with ModeRead, the in-lock preview uses new lock-free
  repo.ListSnapshotsNoLock / LoadSnapshotNoLock (mirroring DeleteSnapshotNoLock);
  the public PreviewForget still takes the Read lock. (A naive PreviewForget call
  inside the lock deadlocked; full prune property suite passes.)
- schedules: a single-value cron step (e.g. "5/10") now expands from that value
  through the field maximum (5,15,25,...) instead of only the start value; test added.
- notifications: an edit whose parameter list was never touched submits an empty
  map so the server preserves the stored (redacted) parameters, instead of the
  form's redacted-derived list replacing them.
Restrict log-directory tightening (0700) to kbackup's own default log
directory (<StateDir>/logs). When an operator points LogFile at a shared
location (e.g. /var/log), its directory permissions are left untouched;
only the log file itself is tightened to 0600 to protect its contents.
chmodOwnerOnly gains a tightenDir flag threaded through newRotatingFile
from logging.go (ownedDir = sc.LogFile == "").
- config: reject an enabled web.listen with an empty port (trailing colon).
  net.SplitHostPort accepts "127.0.0.1:" and the port was discarded, so the
  listener would bind an arbitrary ephemeral port instead of the operator's
  intended one, contradicting the documented explicit-port rule.

- restore (windows): handle a volume/UNC-share root as the restore target.
  filepath.Dir("C:\\")=="C:\\" and Base=="\\", so the anchored parent+leaf open
  passed a bare separator as the object name under a non-null RootDirectory.
  Open such roots directly by NT path (retaining OBJ_DONT_REPARSE). Linux is
  unaffected: openat ignores the dirfd for an absolute path. Adds a
  non-mutating volume-root open test.

- web (jobs): don't validate the hidden source array. The default empty folder
  path stayed validated after switching sourceType to image, blocking submit.
  Source-array items are now validated per the selected sourceType in
  superRefine; excludes keep per-item validation. Adds a regression test.

- api (mounts): drop the stale "Unimplemented until M13" note from
  MountService's doc comment. The service is implemented and tested on both
  platforms (fuse/winfsp) and wired through the web bridge; regenerated Go+TS.

retry.go listing buffering is intentionally unchanged: buffering the page is
required for exact-snapshot listing consistency (streaming was a reverted
regression).
handleEvents discarded EventService.Subscribe's error and just closed the
frame channel, so a slow client whose bounded backlog overflowed (Subscribe
returns ResourceExhausted) saw a clean 200 stream end indistinguishable from a
normal completion -- it could silently miss events with no signal.

Capture the Subscribe result and, when it fails while the stream is still live
(ctx not cancelled), emit a distinguishable `event: error` SSE frame carrying
the uniform JSON error envelope before closing. Server-fault statuses are
logged and reported generically, matching writeGRPCError, so backend internals
never reach a remote client. A context-cancel close (client gone / daemon
shutdown) stays a normal end.

Adds TestSSEStreamSurfacesSubscribeError.

retry.go listing buffering remains intentionally unchanged (required for
exact-snapshot listing consistency).
- snapshots: key the restore dialogs by repo+snapshotId so react-router
  reusing SnapshotDetail across a param-only navigation remounts them with
  fresh state. Otherwise a dialog's local state -- most dangerously
  RestoreImageDialog's satisfied type-to-confirm gate -- persisted and would
  submit the destructive whole-volume restore against the newly navigated
  snapshot. Keyed on repo+snapshotId only, so browsing the tree still keeps the
  files dialog mounted (it re-derives scope on open).

- schedules: fix the next-fire preview's day-of-month / day-of-week rule for a
  stepped wildcard. fieldValues returns null only for a literal "*", so "*/2"
  produced a non-null set and dayMatches treated both day fields as restricted,
  applying OR. Per crontab(5) a day field containing "*" is unrestricted, so
  the fields must be ANDed. Track whether each original field contains "*"
  separately from its value set. Adds a "0 0 */2 * 1" regression test.
- repo/format: charge the unlock KDF-work budget only for keyfiles that will
  actually run Argon2. parseKeyfile now decodes and validates the salt and
  wrapped key, so KeyfileKDFParams rejects a malformed keyfile before unlockAny
  charges it; previously two maximum-cost keys with undecodable base64 could
  exhaust the budget without running any Argon2 and wrongly refuse a repository
  that still held a valid key. OpenKeyfile reuses the decoded fields (single
  parse). Adds format- and repo-level regression tests.

- restore (windows): normalize an absolute symlink target through ntPath so
  extended-length (\?\...) and device (\.\...) prefixes are recognized before
  the plain UNC form. Previously "\?\C:\dir" was misclassified as UNC and
  encoded as the malformed "\??\UNC\?\C:\dir". Adds buffer tests for
  extended-length drive/UNC and device targets.

- web (jobs): model snapshotSizeMb (an int64 in the contract) as a validated
  decimal string instead of a JavaScript number, which silently rounded values
  above Number.MAX_SAFE_INTEGER before the submit path re-widened them with
  BigInt. Adds a round-trip test for 2^53 + 1.

retry.go listing buffering remains intentionally unchanged (required for
exact-snapshot listing consistency).
- config: require an explicit numeric web.listen port in 1-65535. The prior
  check only rejected an empty port, so "127.0.0.1:notaport" and port 0 still
  passed validation and then failed to bind or bound an ephemeral port. Parse
  the port with strconv.Atoi and range-check it. Adds non-numeric/zero/too-large
  test cases.

- restore (windows): remove the placeholder when a symlink restore fails after
  FILE_CREATE. If symlinkReparseBuffer or FSCTL_SET_REPARSE_POINT fails (e.g.
  missing symlink privilege), the empty placeholder was left at the target,
  modifying the destination and making a later no-overwrite restore fail with
  ErrExists. Best-effort remove via the existing delete-on-close path, preserving
  the original error.

- web/auth: return a generic 500 (logged) for a KDF or persistence failure during
  password change instead of a 400 echoing the internal error, which could leak
  the auth-state filesystem path. ChangePassword now marks caller-input failures
  (wrong current password, policy violation) with ErrPasswordRejected; only those
  map to 400. Adds classification and handler tests.

- web/schedules: surface a failed jobs query with a retry control and an
  explanatory disabled-button title, instead of silently disabling New schedule
  as if there were simply no unscheduled jobs.

retry.go listing buffering remains intentionally unchanged (required for
exact-snapshot listing consistency).
The restore root was verified/created by full path (prepareRestoreRoot) and then
its parent was opened by full path again, so a local attacker who renamed or
replaced an ancestor of the target between the two path resolutions could
redirect the root descriptor -- and thus every descriptor-relative restore write.

Resolve the allowed symlinked ancestors once into a stable parent handle, then
create (if absent), verify, and open the root's own final component relative to
that handle:

- linux: open the parent following symlinks, then Mkdirat/Openat the leaf
  relative to the parent fd with O_NOFOLLOW|O_DIRECTORY. A symlink or
  non-directory leaf is refused by the open itself.
- windows: create missing ancestors, open the parent following reparse points,
  then create-or-open the leaf relative to that handle with FILE_OPEN_IF,
  OBJ_DONT_REPARSE, and FILE_DIRECTORY_FILE.

Missing ancestor directories are still created path-based (they are the caller's
destination path, which may cross symlinked/junctioned mounts by design); only
the root leaf and everything beneath it are anchored. The shared path-based
prepareRestoreRoot is removed. Validated on real Linux (Incus, kernel 6.12) and
native Windows; existing tests cover root creation, symlinked-ancestor-above-root
acceptance, and root-leaf-symlink refusal.

retry.go listing buffering remains intentionally unchanged (required for
exact-snapshot listing consistency).
Round-25 anchored the root leaf to the parent handle but still ran an
unconditional os.MkdirAll(parent) before the path-based parent open, leaving a
window in which an attacker could swap an ancestor between the mkdir and the
open. Open the parent directly first and only fall back to creating the ancestor
chain when it is genuinely missing, so in the common case (the parent already
exists) the parent is opened in a single path resolution with no preceding
path-based mkdir to race against. Ancestors are still followed through symlinks
(the caller-chosen destination may cross symlinked mounts, by design); only a
truly missing parent triggers a path-based create+reopen, and those ancestors
did not exist to be swapped beforehand. Applied on Linux (openRestoreRootParent)
and Windows (retry openRootHandle only on a not-found parent). Validated on Incus
(Linux, kernel 6.12) + native Windows.

retry.go listing buffering remains intentionally unchanged (required for
exact-snapshot listing consistency).
Switching an existing password-authenticated SFTP repository to private-key auth
could leave both methods populated: the auth-switch pre-checks "Clear saved
password", but the operator could uncheck it, and submit then sent
clearPassword: false with an empty password (which the API preserves), so the
save carried both a stored password and a private key -- which the backend
rejects (it requires exactly one).

Force the inactive auth method's clear flag at submit (clearPassword when using
private-key, clearPrivateKeyPassphrase when using password), and only show each
clear-secret checkbox for its active method so the inactive one cannot be
un-cleared. Adds a regression test (private-key repo saves clearPassword: true)
and a jsdom ResizeObserver stub the Radix-based form needs under test.

retry.go listing buffering remains intentionally unchanged (required for
exact-snapshot listing consistency).
When the restore root's parent directory was missing, round-26 fell back to a
path-based os.MkdirAll plus a path-based reopen, leaving a window in which an
attacker could plant a symlink among the components being created and redirect
the restore. Create the missing parent under the nearest existing ancestor
instead: walk up to the nearest ancestor that exists, open it once (following
symlinks -- the caller's destination may cross symlinked/junctioned mounts),
then create and open each missing component relative to the previous handle with
O_NOFOLLOW (Linux) / OBJ_DONT_REPARSE + FILE_OPEN_IF (Windows). A reparse point
or symlink planted at a component being created is refused rather than followed.
Adds a cross-platform test for the missing-parent case. Validated on Incus
(Linux, kernel 6.12) + native Windows.

Also clarifies the unlock KDF-work budget comment: the guarantee is a bounded
total (about all-16-default-keyfiles of Argon2 work), which happens to admit
roughly two maximum-cost attempts -- the same total CPU either way. The budget
is left as-is: capping "one maximum-cost attempt" via a two-tier model would
admit 15 default + 1 max-cost (a higher total) or reject legitimate mixed
repositories.

retry.go listing buffering remains intentionally unchanged (required for
exact-snapshot listing consistency).
The progress store copied the entire run->event map on every progress event
(O(total runs) per update) and never evicted completed runs, so a long-lived
session with recurring jobs grew it without bound. Mutate the map in place --
useRunProgress snapshots per-run values, so a fresh event object for the run is
enough to re-render -- and drop a run's entry when its SUMMARY arrives (the
completed run is shown from the refetched runs query, not live progress). The
map now holds only active runs. Adds a store test for the eviction.
- restore: in overwrite mode, remove the existing leaf and create a fresh file
  (O_CREAT|O_EXCL|O_NOFOLLOW on Linux, FILE_CREATE after a reparse-safe remove on
  Windows) instead of truncating the existing inode in place. Truncating would
  corrupt every other path hard-linked to the same inode. openFile now owns this
  for both create and overwrite, so restoreFile's now-redundant
  removeExistingSymlink is dropped. Adds a hard-link-severing regression test;
  validated on Incus (Linux) + native Windows.

- restore: resolve the snapshot and requested subpath before opening/creating the
  destination root, so a nonexistent snapshot, bad manifest, or invalid subpath
  no longer leaves freshly created destination directories behind.

- web (schedules): disable New schedule until BOTH jobs and schedules have loaded
  (a jobs-before-schedules window otherwise treated already-scheduled jobs as
  available), and revalidate at submit that a new schedule's selected job is
  still unscheduled.

retry.go listing buffering remains intentionally unchanged (required for
exact-snapshot listing consistency).
- ApplyForget endpoint now rejects unbound applies (InvalidArgument): every
  apply must set bind_expected_forget_ids so a client cannot preview one set
  and delete whatever the policy later resolves to. The TUI now binds its
  previewed forget set; the automated space-reclaim path stays unbound via
  prune.ApplyForget directly, not this endpoint.
- image.OpenFileTarget replaces the target inode (remove + O_CREATE|O_EXCL)
  instead of truncating in place, so restoring over a hard-linked file no
  longer corrupts its siblings and never follows a symlink at the path.
- Web cron validation accepts case-insensitive JAN-DEC / SUN-SAT names in the
  month and day-of-week fields; numeric-only fields still reject names.
Restoring an image file target removed the leaf then re-created it via a
second full-path resolution, so an attacker who swapped an ancestor directory
for a symlink between the two calls could redirect where the image was created
(O_EXCL bound only the leaf, not the parent used by the removal). Mirror the
file-restore path: open the target parent once (following symlinks, since the
operator-chosen destination may legitimately cross symlinked mounts), then
unlink and exclusively create the leaf RELATIVE to that pinned handle with
O_NOFOLLOW (Linux openat/unlinkat) / OBJ_DONT_REPARSE (Windows NtCreateFile
with the parent as RootDirectory). Binding both operations to one parent inode
closes the ancestor-swap window and still refuses a symlinked leaf.

Validated on Incus (kernel 6.12) and native Windows; hard-link severing,
sparse-hole, and full image-restore tests pass on both.
fieldValues resolved non-name tokens with Number(), which coerces "" to 0 and
"0x10" to 16, so malformed expressions like ",5 * * * *" or "0x10 * * * *"
passed isValidCron and could be saved/previewed as valid standard cron the
daemon may reject or interpret differently. Require non-name value and step
tokens to match ^\d+$ (a plain decimal integer) before Number(); names
(JAN-DEC / SUN-SAT) are still resolved first. Empty comma-separated parts now
fail as well. Adds regression tests for the empty, hex, and exponent forms.
The image file-target restore removed the existing target and then created its
replacement, so a failure creating or sizing the replacement (or a create race)
left the original destroyed with only an empty file in its place. Stage instead:
create a fresh temporary inode beside the target under the anchored parent
handle, size it, and only then atomically rename it over the target. A failure
during create or sizing now removes just the temporary and leaves the original
untouched. The rename still severs any hard link to the old inode (replacing the
directory entry) and stays anchored/no-follow.

Linux uses openat/ftruncate/renameat relative to the parent fd; Windows uses
NtCreateFile relative to the parent handle and NtSetInformationFile
(FileRenameInformation, REPLACE_IF_EXISTS) to rename within the file's own
directory. Adds a regression test that a failed sizing preserves the original
and leaves no stray temporary. Validated on Incus (kernel 6.12) and native
Windows.
nextCronFire decided the crontab(5) day-of-month / day-of-week OR-vs-AND rule
with fields[i].includes("*"), but Vixie cron treats a day field as unrestricted
only when it *begins* with "*". A field like "1,*/2" contains a "*" yet starts
with a value, so it is restricted and must OR with a restricted day-of-week.
The old check wrongly treated it as unrestricted and ANDed, so "0 0 1,*/2 * 1"
previewed the next fire as Mon Jul 27 instead of Mon Jul 20. Use startsWith("*")
and add a regression test for the mixed-list case.
The file-restore overwrite path diverged by platform: Linux unlinkat(flag 0)
fails on a directory, but the Windows rt.remove opened the leaf without
FILE_NON_DIRECTORY_FILE and with FILE_DELETE_ON_CLOSE, so an overwrite restore
targeting an existing empty directory deleted that directory and created a file
in its place -- silently destroying it. Make both platforms explicitly refuse a
real directory with the same typed ErrExists before removing anything (a reparse
point is reported as a symlink, not a directory, so junctions/symlinks are still
replaced). Adds a cross-platform regression test that an overwrite over an empty
directory is refused and leaves the directory intact. Validated on Incus (kernel
6.12) and native Windows.
- notifications: url/parameters/paramsTouched/clearParams are shared across the
  channel type field, so switching from one type to another (e.g. webhook ->
  shoutrrr and back) carried the draft URL/parameters into a submit under the
  original type. Because a same-type edit only preserves the stored secret when
  those fields are blank, the draft overwrote the channel's stored config.
  Reset the shared draft state whenever the type changes.
- progress: forward `value` to ProgressPrimitive.Root so Radix sets aria-valuenow
  and the determinate data-state; previously value was destructured out and only
  drove the indicator transform, so screen readers announced every determinate
  bar as indeterminate. Adds a render test for the forwarded value.
The staged replacement is a fresh inode (to sever hard links), created with the
0644 default, so restoring over a restrictive 0600 image widened it to 0644 --
group/other readable under a typical umask. The previous in-place O_TRUNC open
kept an existing file's mode. Capture an existing regular target's permission
bits with a no-follow fstatat before staging and fchmod the temporary to match
before the rename; a missing or non-regular target keeps the create default.
Adds a Linux regression test that a 0600 target stays 0600 after a restore.
Validated on Incus (kernel 6.12).
- image (Windows): the staged replacement is a fresh inode, so it inherited the
  parent directory's default security and a target with a custom/protected DACL
  could become accessible to principals the original denied. Capture the existing
  regular target's DACL (no-follow, via GetSecurityInfo) and apply it PROTECTED to
  the staged file (WRITE_DAC) before the rename, mirroring the Linux mode-bit
  preservation; owner is left as the restoring user. Adds a native regression test
  that a protected DACL survives replacement.
- repository-form: minFreeBytes accepted any decimal string, but minFree.bytes is
  a uint64, so values above 18446744073709551615 failed at the daemon. Reject them
  in the form via a BigInt upper-bound check, with boundary regression tests.
The DACL copy always marked the replacement protected, so an existing target
whose DACL inherited from its parent lost that inheriting semantics (its ACEs
were frozen and flagged protected). Capture the original DACL's protection state
and apply the matching flag: a protected DACL is copied verbatim; an unprotected
one is applied UNPROTECTED so Windows recomputes the inheritable ACEs from the
parent, reproducing the original's effective DACL while keeping it inheriting.
Owner is still left as the restoring account (mirroring the Linux mode-only
preservation). Adds a regression test that an unprotected target stays
unprotected after replacement.
Retention counts (RetentionSpec) and maxConcurrentJobs / pruneMinPackAgeMinutes
(ServiceSettingsSpec) are int32 in the generated contract, but the forms only
enforced a lower bound, so a value above 2147483647 passed client validation and
would fail server-side or be coerced on encode. Add the int32 maximum to the
retention validator and the two service-settings fields. Adds a job-form
boundary test; the settings fields use the same z.number().int().max pattern.
On Windows the log tightening was a no-op, so an operator-configured log in a
shared directory inherited that directory's ACL and could stay readable by
ordinary local users despite containing source paths, job details, and backend
errors -- unlike Linux, which tightens the file itself to 0600 even for a custom
path. Apply a protected NTFS DACL (owner + LocalSystem + Administrators) to the
log file, mirroring the Linux 0600 tightening. Thread ownedDir through the
rotating writer and re-apply the tightening after each rotation (best-effort) so
a freshly created rotated file is covered too. Adds a native Windows test that
the log DACL is protected and excludes Everyone; Linux behavior unchanged
(validated on Incus).
kleb merged commit dae96b395a into main 2026-07-19 21:08:58 +02:00
kleb deleted branch audit-and-web-ui 2026-07-19 21:08:58 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kleb/kBackup!3
No description provided.