Skip to content

COG-1160: W1 AdminPortal XSRF-TOKEN plumbing (trailing-slash audit: no changes needed) - #344

Open
patrickbradley-cog wants to merge 1 commit into
masterfrom
devin/cog-1160-w1-adminportal-xsrf-token-plumbing-fix-trailing-slash-url
Open

patrickbradley-cog wants to merge 1 commit into
masterfrom
devin/cog-1160-w1-adminportal-xsrf-token-plumbing-fix-trailing-slash-url

Conversation

@patrickbradley-cog

@patrickbradley-cog patrickbradley-cog commented Sep 30, 2026 •

Copy link
Copy Markdown

Summary

Linear: COG-1160. This is a W1 forward-compatible change: TypeScript only, no Java changes, no version bumps. It builds on the COG-1147 audit (#340, docs/adminportal-http-inventory.md).

  1. Trailing slashes: no code change. The audit found that none of the 9 AdminPortal URL literals ends in /, so there is nothing to remove.
  2. New AdminPortal/src/app/xsrf.ts helper.
    readXsrfToken(): string                // XSRF-TOKEN cookie value, or null
    xsrfHeaders(headers = new Headers())   // sets X-XSRF-TOKEN only if the cookie exists
    xsrfOptions(headers?) => { headers: xsrfHeaders(headers), withCredentials: true }
    The header is only added when the cookie is present (audit item M6). A custom header forces a CORS preflight, and the Boot 1.5.4 RequestFilter preflight does not list x-xsrf-token in Access-Control-Allow-Headers. While server CSRF is disabled there is no cookie, so no header and no preflight: nothing changes on the wire today.
  3. login.service.ts
    sendCredential = GET /index (withCredentials, errors swallowed)
                     .mergeMap(() => POST /index, xsrfOptions(form-urlencoded headers))
    
    The bootstrap deliberately uses GET /index, not GET /api/csrf. I tested /api/csrf against master with curl. On master it is an authenticated URL, so Spring Security saves it as the post-login target, and the login POST /index then redirects to it:
    GET /api/csrf  302 -> /index
    POST /index    302 -> /api/csrf     <- login would land on a 404 instead of /userFront
    
    GET /index is permitAll, so it is never saved. It also renders the th:action login form, which reads the CSRF token, so once W2 turns CSRF on with CookieCsrfTokenRepository this same request writes the XSRF-TOKEN cookie. After COG-1159 (COG-1159: W1 · Add GET /api/csrf token-bootstrap endpoint (204) #334) lands, switching the bootstrap to /api/csrf becomes safe, but it isn't needed.
  4. withCredentials is already set on all 9 calls (per the audit) and on the new bootstrap GET.

Out of scope, file-disjoint from sibling PRs:

  • appointment.service.ts belongs to COG-1153 and user.service.ts to COG-1154. Each currently inlines its own cookie read. Both should switch to xsrfOptions() from ./xsrf when rebased on this PR.
  • logout() stays a GET (audit item K1, which only applies at W5).
  • RequestFilter is left alone (that is COG-1165).

For the server side (W2), still not done here: configure csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()). Otherwise Security 6+ rejects the plain cookie value this client echoes back.

Verification (Boot 1.5.4 unmodified, Temurin 8, mysql:5.7, Node 12)

$ mvn -B -f UserFront/pom.xml verify         # Tests run: 1, Failures: 0  BUILD SUCCESS
$ node node_modules/@angular/cli/bin/ng build   # OK (main.bundle.js 37.2 kB)
$ tslint src/app/xsrf.ts src/app/login.service.ts
  # xsrf.ts: clean. login.service.ts: the same 11 errors master already has (let/const, whitespace), nothing new
$ curl -H 'Origin: http://localhost:4200' GET /index     # 200, ACAO localhost:4200, ACAC true, no XSRF-TOKEN cookie
$ curl GET /index ; POST /index admin                     # 302 -> /userFront

Browser run-through (login -> user list -> statements -> appointments) is posted in a PR comment below.

Link to Devin session: https://app.devin.ai/sessions/c0c85e18808b4bc799be66afa6c80916
Open in Devin Desktop: https://app.devin.ai/desktop/session/c0c85e18808b4bc799be66afa6c80916?variant=devin
Requested by: @patrickbradley-cog


Devin Review

Status Commit
⚪ Not started —

Run Devin Review

Devin Review (Staging)


Devin Review

…minPortal login

Co-Authored-By: patrick.bradley <patrick.bradley@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +14 to +15
if (token) {
headers.set(XSRF_HEADER_NAME, token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Existing XSRF cookie blocks admin login

When localhost has an XSRF-TOKEN cookie, xsrfOptions adds a header that triggers a preflight. RequestFilter omits that header from its allowlist, so the browser blocks login.

Learn more

The AdminPortal runs at localhost:4200 and posts credentials to localhost:8080, so its requests are cross-origin. The existing RequestFilter answers OPTIONS requests without permitting x-xsrf-token. A browser sends that preflight as soon as the cookie is present, even when server-side CSRF is disabled. The preflight fails, and the browser never sends the login POST.

Example: A different localhost application previously set XSRF-TOKEN=abc with path /. An admin loads the portal and submits valid credentials. The new header triggers OPTIONS /index; the browser rejects the response and the admin cannot log in.

Recommended fix: Coordinate the header rollout with an update to RequestFilter that permits x-xsrf-token on preflight responses. Verify login with a cookie already present, not just a fresh browser profile.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This does happen, and it's a known edge case of the design the audit prescribes (COG-1147 item M6). Adding the header only when the cookie exists is what keeps it from firing on a clean browser against the 1.5.4 server. The real fix is the one you point to: allow x-xsrf-token in the preflight. That belongs to COG-1165 (CorsConfig replacing RequestFilter), which is out of scope for this TypeScript-only W1 ticket, and it has to land before or with COG-1166 anyway. Until then, the trigger is a stray XSRF-TOKEN cookie set on localhost by some other app, which doesn't happen in the normal dev or prod setup. Leaving the code as is and flagging it for COG-1165.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Follow-up from browser testing: this already happens on master. Angular 4 HttpModule registers CookieXSRFStrategy('XSRF-TOKEN', 'X-XSRF-TOKEN') by default (@angular/http XHRConnection → configureRequest). So any AdminPortal request already carries X-XSRF-TOKEN whenever that cookie exists, and hits the same preflight rejection. Testing with a synthetic cookie confirmed it: the bootstrap GET, which doesn't use xsrfOptions, was preflighted and blocked the same way. The fix is still the COG-1165 allow-list.

// GET /index is permitAll, so unlike a protected URL it is not stored as the post-login redirect target.
private fetchCsrfToken(): Observable<any> {
const url = 'http://localhost:8080/index';
return this.http.get(url, { withCredentials: true }).catch(() => Observable.of(null));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Bootstrap failures are invisible to login

fetchCsrfToken converts every GET error into a value, then attempts login. Once token issuance depends on that GET, the login UI cannot distinguish a bootstrap failure from a rejected login.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is intentional. The bootstrap GET is best-effort, and a failure there shouldn't stop the login attempt: while server CSRF is off, the POST works without a token. Once COG-1166 turns CSRF on, a failed bootstrap means the POST goes out without X-XSRF-TOKEN, the server rejects it with 403, and that error reaches LoginComponent's err callback. So the failure does surface, just as a failed login. I'm not adding a separate error state for the bootstrap here, because it would change the login component's UX, which is outside this ticket.

@devin-ai-integration

Copy link
Copy Markdown

Browser verification (Temurin 8 · Boot 1.5.4 unmodified · mysql:5.7 · AdminPortal ng serve on Node 12)

Cold login: GET /index 200 → POST /index 302 Location: /userFront → GET /userFront 200. No XSRF-TOKEN cookie, no X-XSRF-TOKEN header, no OPTIONS preflight.

Regression: the user list shows admin at 0/0 and alice at 250/100. Alice's primary statement shows a 250 deposit and her savings statement a 100 deposit. Her appointment (Mortgage review, unconfirmed) is listed.

Login Network sequence
Login Network sequence
Bootstrap GET /index Credential POST /index → 302
GET POST
User list Alice primary statement
Users Primary
Alice savings statement Appointments
Savings Appointments
Edge case: stray XSRF-TOKEN cookie (expected limitation until COG-1165)

With a synthetic XSRF-TOKEN=abc cookie on localhost, the header is attached and the legacy RequestFilter preflight rejects x-xsrf-token, so Chrome blocks the request. The bootstrap GET is blocked too. That GET does not go through xsrfOptions: Angular 4 HttpModule's default CookieXSRFStrategy attaches X-XSRF-TOKEN to every request whenever this cookie exists. So master already behaves this way for every AdminPortal call, and this PR doesn't introduce it. The fix is the CORS allow-list in COG-1165. The synthetic cookie was removed afterward.

Header attached CORS rejection
Header CORS

Cookie edge recording

Primary flow recording

Primary recording

Not tested: a CSRF-enabled server (that's W2).
Unrelated, seen during setup: navbar logout reloads before its async cleanup finishes, which can leave stale logged-in UI. navbar.component.ts isn't touched by this PR.

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.

1 participant