Skip to content

fix(desktop): add CSRF protection to mutating requests in library, framework-api, memory-api, x-monitor - #2165

Open
hognek wants to merge 3 commits into
jaylfc:devfrom
hognek:fix/2126-library-csrf
Open

fix(desktop): add CSRF protection to mutating requests in library, framework-api, memory-api, x-monitor#2165
hognek wants to merge 3 commits into
jaylfc:devfrom
hognek:fix/2126-library-csrf

Conversation

@hognek

@hognek hognek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds CSRF protection via a globally-installed auth guard to all three desktop entry points.

Previous PR state: installAuthGuard() was only in main.tsx (desktop shell PWA).
chat-main.tsx (chat PWA) and app-standalone-main.tsx (standalone app PWA) had no guard,
so mutating API calls made from those PWAs would fail CSRF validation.

jaylfc review follow-up (2026-07-27)

  • Added installAuthGuard() to desktop/src/chat-main.tsx (chat PWA entry)
  • Added installAuthGuard() to desktop/src/app-standalone-main.tsx (standalone app PWA entry)
  • New test desktop/src/tests/entry-guards.test.ts asserting all three entry modules install the guard on import

Testing

  • 3 new entry-guard tests: PASS
  • Full vitest suite: 373 files, 3177 tests: ALL PASS
  • SPA build: clean (4.84s)
  • TypeScript: clean (tsc --noEmit)

Closes #2126

Summary by CodeRabbit

  • New Features

    • Added consistent session-expiration handling across desktop app entry points, automatically showing the login screen when authentication expires.
  • Bug Fixes

    • Strengthened security by applying CSRF protection to framework, library, memory, and monitoring requests.
  • Tests

    • Added coverage verifying authentication guard installation across desktop startup flows.
    • Added a CSRF regression test ensuring the token is correctly forwarded for JSON fetch requests.

…amework-api, memory-api, x-monitor

Four lib modules were using raw fetch() on mutating HTTP methods
(POST/PUT/DELETE) without the withCsrf wrapper, bypassing the
double-submit CSRF check.

Affected functions:
- library.ts: deleteLibraryItem, reprocessLibraryItem, ingestLibraryUrl
- framework-api.ts: startFrameworkUpdate, setPermittedModels
- memory-api.ts: setMemoryModel
- x-monitor.ts: postJson/putJson helpers, deleteWatch

Fixes jaylfc#2126
@hognek
hognek marked this pull request as ready for review July 27, 2026 12:39
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 09f77af3-a379-4614-bf6c-87b923ce7e80

📥 Commits

Reviewing files that changed from the base of the PR and between b772ce8 and 12c5959.

📒 Files selected for processing (2)
  • desktop/src/lib/x-monitor.ts
  • desktop/tests/x-monitor.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • desktop/src/lib/x-monitor.ts

📝 Walkthrough

Walkthrough

Desktop entrypoints now install an authentication guard, and mutating requests in framework, library, memory, and X monitor clients now use withCsrf. Tests verify guard installation and CSRF header forwarding.

Changes

Desktop authentication and request protection

Layer / File(s) Summary
Auth guard entrypoints and coverage
desktop/src/chat-main.tsx, desktop/src/app-standalone-main.tsx, desktop/src/__tests__/entry-guards.test.ts
The chat and standalone entrypoints install installAuthGuard(), with isolated tests verifying installation across all three entry modules.
Framework and memory CSRF requests
desktop/src/lib/framework-api.ts, desktop/src/lib/memory-api.ts
Framework update, permitted-model, and memory-model mutations wrap fetch options with withCsrf.
Library and monitor CSRF requests
desktop/src/lib/library.ts, desktop/src/lib/x-monitor.ts, desktop/tests/x-monitor.test.ts
Library mutations and X monitor POST, PUT, and DELETE requests use CSRF-protected options; a regression test verifies forwarding of the CSRF header.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EntryModule
  participant installAuthGuard
  participant window.fetch
  participant LoginGate
  EntryModule->>installAuthGuard: install during startup
  installAuthGuard->>window.fetch: wrap API requests
  window.fetch-->>installAuthGuard: 401 response from /api/*
  installAuthGuard->>LoginGate: emit session-expired signal
Loading

Possibly related PRs

  • jaylfc/taOS#746: Adds the CSRF helper and related mutating-request wiring used by these desktop clients.
  • jaylfc/taOS#1991: Adds related installAuthGuard() fetch-wrapper behavior.
  • jaylfc/taOS#2174: Modifies the same desktop entrypoints and auth-guard tests.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The auth-guard entry-point changes and test are unrelated to issue #2126's library CSRF fix. Split the auth-guard work into a separate PR and keep this one focused on the library CSRF fix.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding CSRF protection to desktop mutating requests.
Linked Issues check ✅ Passed library.ts now routes all three mutating calls through withCsrf as requested by issue #2126.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitar-bot

gitar-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

fix(desktop): add CSRF wrapper to mutating lib API calls

🐞 Bug fix 🕐 20-40 Minutes

Grey Divider

AI Description

• Wrap POST/PUT/DELETE requests with withCsrf() across four desktop lib modules
• Ensure cookie-authenticated mutating /api calls include the X-CSRF-Token header
• Align older modules with existing CSRF double-submit pattern used elsewhere
Diagram

graph TD
A["desktop/src/lib/library.ts"] --> C["desktop/src/lib/csrf.ts (withCsrf)"] --> F["Backend /api (verify_csrf)"]
B["desktop/src/lib/framework-api.ts"] --> C --> F
D["desktop/src/lib/memory-api.ts"] --> C --> F
E["desktop/src/lib/x-monitor.ts"] --> C --> F
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Install a global fetch wrapper/guard for CSRF on mutating /api calls
  • ➕ Eliminates per-module omissions; new code gets protection automatically
  • ➕ Centralizes CSRF behavior, logging, and future changes
  • ➖ Higher blast radius: can affect third-party or non-/api fetches if not carefully scoped
  • ➖ Harder to reason about in tests due to implicit behavior
2. Introduce shared JSON helpers (postJson/putJson/deleteJson) used across modules
  • ➕ Reduces repeated fetch boilerplate and standardizes headers/body handling
  • ➕ Makes CSRF usage the default via helper composition
  • ➖ Requires refactoring call sites; larger diff than necessary for a security hotfix
  • ➖ Some modules may need custom behavior that fights the abstraction
3. Add a lint rule / code review gate to forbid raw fetch for POST/PUT/DELETE
  • ➕ Prevents regression and enforces security convention consistently
  • ➕ Low runtime risk; purely developer workflow improvement
  • ➖ Does not fix existing code by itself; needs ongoing rule maintenance
  • ➖ May require false-positive suppressions for legitimate non-cookie flows

Recommendation: The PR’s approach (explicitly wrapping the known missing mutating calls with withCsrf) is the safest, lowest-risk security fix and matches existing patterns in other lib modules. Consider a follow-up to add a lint/review guard (and optionally shared JSON helpers) to prevent future raw mutating fetch usage without CSRF.

Files changed (4) +25 / -17

Bug fix (4) +25 / -17
framework-api.tsWrap framework update/permitted-model mutations with CSRF +6/-4

Wrap framework update/permitted-model mutations with CSRF

• Imports withCsrf() and wraps the POST startFrameworkUpdate and PUT setPermittedModels fetch() RequestInit. Ensures CSRF header is attached for cookie-authenticated mutating calls.

desktop/src/lib/framework-api.ts

library.tsAdd CSRF to library delete/reprocess/ingest mutations +8/-6

Add CSRF to library delete/reprocess/ingest mutations

• Imports withCsrf() and wraps DELETE deleteLibraryItem, POST reprocessLibraryItem, and POST ingestLibraryUrl. Prevents these library mutations from bypassing backend verify_csrf.

desktop/src/lib/library.ts

memory-api.tsProtect memory model PUT with CSRF header +4/-2

Protect memory model PUT with CSRF header

• Imports withCsrf() and wraps the PUT request in setMemoryModel. Keeps error handling and response parsing unchanged while ensuring CSRF compliance.

desktop/src/lib/memory-api.ts

x-monitor.tsApply CSRF to mutating JSON helpers and deleteWatch +7/-5

Apply CSRF to mutating JSON helpers and deleteWatch

• Imports withCsrf() and wraps postJson/putJson helper RequestInit objects, and the DELETE init used by deleteWatch. Ensures all x-monitor mutating requests consistently send X-CSRF-Token.

desktop/src/lib/x-monitor.ts

@qodo-code-review

qodo-code-review Bot commented Jul 27, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 35 rules

Grey Divider


Action required

1. Headers lost via spread ✓ Resolved 🐞 Bug ≡ Correctness
Description
In x-monitor.ts, postJson/putJson/deleteWatch now pass withCsrf(...) into fetchJson(), but fetchJson
merges headers via object spread which drops Headers instances. This strips X-CSRF-Token (and
Content-Type for POST/PUT), so these mutating requests can fail CSRF verification and/or be parsed
incorrectly by the backend.
Code

desktop/src/lib/x-monitor.ts[R67-79]

+  return fetchJson(url, fallback, withCsrf({
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
-  });
+  }));
}

async function putJson<T>(url: string, body: unknown, fallback: T): Promise<T> {
-  return fetchJson(url, fallback, {
+  return fetchJson(url, fallback, withCsrf({
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
-  });
+  }));
Relevance

⭐⭐⭐ High

Team recently accepted CSRF-related mutating-request fixes; header-dropping bug would undermine that
protection (PR #2122).

PR-#2122

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
withCsrf() replaces init.headers with a Headers object, but fetchJson() assumes headers are
a plain object and uses object spread to merge them. Spreading a Headers instance results in an
empty object, so the final fetch() call only contains the hard-coded Accept header and drops both
CSRF and Content-Type.

desktop/src/lib/csrf.ts[26-34]
desktop/src/lib/x-monitor.ts[54-80]
desktop/src/lib/x-monitor.ts[149-156]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`withCsrf()` returns `RequestInit` whose `headers` is a `Headers` instance when a CSRF cookie exists. In `desktop/src/lib/x-monitor.ts`, `fetchJson()` merges headers using object spread (`{ ...init?.headers }`), which drops `Headers` entries, causing the request to lose `X-CSRF-Token` and (for POST/PUT) `Content-Type`.

### Issue Context
This PR wrapped `postJson`/`putJson`/`deleteWatch` with `withCsrf(...)`, which triggers the `Headers`-typed `init.headers` path.

### Fix Focus Areas
- desktop/src/lib/x-monitor.ts[54-80]

### Suggested fix
Update `fetchJson()` in `x-monitor.ts` to merge headers using the `Headers` API, e.g.:
- `const headers = new Headers(init?.headers);`
- `headers.set("Accept", "application/json");`
- `return fetch(url, { ...init, headers });`

This preserves `Content-Type` and `X-CSRF-Token` regardless of whether `init.headers` is a plain object or a `Headers` instance.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread desktop/src/lib/x-monitor.ts
@kilo-code-bot

kilo-code-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • desktop/src/lib/x-monitor.ts - 0 issues
  • desktop/tests/x-monitor.test.ts - 0 issues
Previous Review Summary (commit ca4d9d2)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit ca4d9d2)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
Issue Details (click to expand)

CRITICAL

File Line Issue
desktop/src/lib/x-monitor.ts 79 fetchJson() spreads init.headers with object syntax, which drops Headers instances returned by withCsrf() when a CSRF cookie is active. This strips X-CSRF-Token and Content-Type, causing mutating requests in postJson/putJson/deleteWatch to fail CSRF verification and/or be misrouted by the backend.
Files Reviewed (4 files)
  • desktop/src/lib/framework-api.ts - 0 issues
  • desktop/src/lib/library.ts - 0 issues
  • desktop/src/lib/memory-api.ts - 0 issues
  • desktop/src/lib/x-monitor.ts - 1 issue

@jaylfc

jaylfc commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Reviewed. The change is correct and I am not blocking it, but I found something while checking it that makes this PR more important than it looks, and it is two lines from being complete. Please fold that in here rather than opening a follow-up.

First, what I checked and got wrong so nobody repeats it. My initial suspicion was that withCsrf would lose Content-Type when a caller passed a Headers instance. It does not: csrf.ts:31 uses new Headers(init?.headers), which normalises plain objects, arrays and Headers alike. No bug there.

Second, the thing that actually matters. My next thought was that these call sites are redundant, because auth-guard.ts already patches window.fetch and its own comment says it covers CSRF "at every call site, not only the handful that wrap withCsrf() by hand". That would have made this PR churn. It is not, because the guard is not installed everywhere:

$ grep -rn "installAuthGuard()" desktop/src --include=*.tsx
desktop/src/main.tsx:13:installAuthGuard();

One entry point. There are three:

entry point bundle installAuthGuard restoreActiveTheme
main.tsx desktop shell yes yes
chat-main.tsx chat PWA no yes
app-standalone-main.tsx standalone app PWA no yes

chat-main.tsx and app-standalone-main.tsx both install the theme restore and the WebKit repaint guard, and both skip the auth guard. So in the chat PWA and in every standalone app PWA:

  1. No global CSRF header. Nothing attaches X-CSRF-Token except call sites that wrap withCsrf by hand, which is exactly what this PR is adding. So these changes are load bearing in those bundles, not defence in depth.
  2. No 401 handling. main.tsx:9-12 documents why the guard exists: a stale cookie "left the SPA rendering empty data instead of prompting for re-login". That regression is still live in both PWA bundles today.

Requested change, in this PR: add installAuthGuard() to chat-main.tsx and app-standalone-main.tsx. It is idempotent and already covered by auth-guard.test.ts, so the risk is close to zero and it closes both holes at the source instead of one call site at a time.

Please also add a test that would have caught this, since the current suite passes with the guard installed in only one of three entry points. A test asserting each entry module installs the guard is enough. Without it the same gap reappears the next time an entry point is added, and there is now a fourth on the way.

Worth stating plainly: the per-call-site withCsrf approach cannot be verified by inspection once there are 52 of them, whereas one guard at each entry point can. Keep the call-site wrapping you have added here, since it is harmless and helps under mocked fetch in tests, but the guard is what should be relied on.

Doc sweep and CHANGELOG in the same PR per the commit gate.

chat-main.tsx and app-standalone-main.tsx were missing installAuthGuard(),
leaving the chat PWA and standalone app PWA without CSRF protection on
window.fetch calls. Only main.tsx (desktop shell PWA) had the guard.

Add the import + call to both entry modules, matching the pattern in
main.tsx. installAuthGuard is idempotent (module-level flag), so double
install is safe if an entry point is loaded more than once in the same
realm.

Add entry-guards.test.ts asserting all three entry modules install the
auth guard on import.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@desktop/src/lib/x-monitor.ts`:
- Around line 67-79: Update fetchJson to preserve Headers instances supplied by
withCsrf instead of spreading them into a plain object, retaining X-CSRF-Token
and Content-Type. Ensure the request headers remain valid for the postJson and
putJson mutation wrappers while keeping existing header-merging behavior for
other inputs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 342732d7-19fb-45d7-8ca1-6872a667b491

📥 Commits

Reviewing files that changed from the base of the PR and between 45d17f5 and b772ce8.

⛔ Files ignored due to path filters (1)
  • desktop/package-lock.json is excluded by !**/package-lock.json, !**/package-lock.json
📒 Files selected for processing (7)
  • desktop/src/__tests__/entry-guards.test.ts
  • desktop/src/app-standalone-main.tsx
  • desktop/src/chat-main.tsx
  • desktop/src/lib/framework-api.ts
  • desktop/src/lib/library.ts
  • desktop/src/lib/memory-api.ts
  • desktop/src/lib/x-monitor.ts

Comment thread desktop/src/lib/x-monitor.ts
@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

This went CONFLICT because #2174 merged and touched auth-guard. Needs a rebase onto current origin/dev.

Worth knowing what landed, because it changes what this PR is for. #2174 installed installAuthGuard() in all three SPA entry points (it was only in main.tsx, so the chat PWA and every standalone app PWA had no global CSRF attachment and no 401 session-expired handling). The guard patches window.fetch and attaches X-CSRF-Token to same-origin mutating requests, so with that merged, the per-call-site withCsrf wrapping in this PR is now genuine defence in depth rather than the only protection in those bundles.

That is not a reason to drop it. Keep the explicit wrapping: it is harmless (withCsrf only sets the header when absent) and it keeps the call sites correct under a mocked fetch in tests, where the global guard is not installed. Just rebase and expect the diff to read differently now.

#2174 also added a coverage test that enumerates entry points from vite.config.ts and fails if any is missing the guard, so a fourth entry point cannot silently ship unprotected. Nothing for you to do there, but do not be surprised by it in the merge.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Correcting myself: the Headers-spread bug IS real, and it is in this PR's blast radius. Earlier I said my suspicion was wrong. I had checked csrf.ts, which is fine, and wrongly concluded the whole class did not exist. The bug is at the call site.

desktop/src/lib/x-monitor.ts:54:

const res = await fetch(url, { ...init, headers: { Accept: "application/json", ...init?.headers } });

withCsrf returns headers as a Headers instance (csrf.ts:31, new Headers(init?.headers)). Spreading a Headers instance into an object literal yields an empty object, because its entries are not own enumerable properties. Measured:

spreading a Headers instance yields: {"Accept":"application/json"}
CSRF token survived? false

spreading a PLAIN object yields:     {"Accept":"...","Content-Type":"...","X-CSRF-Token":"tok123"}

So the moment an x-monitor call is routed through withCsrf, the token this PR exists to add is silently stripped at line 54, and every postJson/putJson in that file inherits it. The failure is invisible: the request still sends, just without the header, so it fails the CSRF gate at the server rather than erroring client-side.

Fix: normalise instead of spreading, so it works for both shapes.

const headers = new Headers({ Accept: "application/json" });
new Headers(init?.headers).forEach((v, k) => headers.set(k, v));
const res = await fetch(url, { ...init, headers });

new Headers(...) already accepts a Headers, a plain object, or an array, which is exactly why csrf.ts is correct and this line is not.

Please add a test that would have caught it: call fetchJson with an init whose headers is a Headers instance carrying X-CSRF-Token, and assert the header reaches fetch. Prove it goes red against the current line 54 before fixing, otherwise the test is only decorating the fix.

This PR is also DIRTY now (#2174 merged and touched auth-guard), so you need a rebase regardless. Fold this into the same pass.

Credit where due: Qodo flagged this and I dismissed it after checking the wrong file. Its finding stands and mine did not.

…regression test

withCsrf() returns a Headers instance at csrf.ts:33.  Spreading that
instance into an object literal (the old fetchJson line 56 pattern)
silently drops every entry because Headers entries are not own-
enumerable properties.  The CSRF token the PR was written to add was
therefore never reaching the wire.

Fix by building a fresh Headers with the Accept default and copying
caller headers via forEach — the iterator visits all entries correctly.

Add a regression test that exercises createWatch → postJson → fetchJson
→ withCsrf with a stubbed csrf_token cookie, asserting the token
survives into the final fetch call.
@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

The Headers fix is correct and I verified it rather than eyeballing it. Ran the old and new logic side by side against exactly what withCsrf returns (a Headers instance carrying X-CSRF-Token):

OLD: token survives?              false
NEW: token survives?              true
NEW: Accept preserved?            true
NEW: plain-object init still works? true

That last line matters as much as the first: new Headers(init?.headers) accepts a Headers, a plain object or an array, so the fix does not break callers passing a plain object literal. That is why normalising is right and spreading was not.

Only the rebase is left. mergeStateStatus is DIRTY because dev moved under you again: #2050 (fail-closed signing) and #2185 (app tiering S1) landed in the last hour or so, on top of #2174 earlier.

One thing to watch on this rebase specifically. #2174 installed installAuthGuard() in all three SPA entry points, so the global window.fetch patch now attaches CSRF to same-origin mutating requests everywhere. Your per-call-site withCsrf wrapping is therefore defence in depth rather than the only protection. Keep it — it is harmless (withCsrf only sets the header when absent) and it keeps the call sites correct under a mocked fetch in tests, where the guard is not installed. Just do not be surprised if the diff reads differently against the new base.

And run the check that saved #2177: git log <merge-base>..origin/dev over the files you touch, before pushing. You are touching desktop/src/lib/ which several merges have moved through tonight, and a rebase can silently drop merged work with no conflict marker.

Rebase and I will merge it. Thanks for turning that one around quickly, and credit to Qodo for the original catch that I initially dismissed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants