refactor(frontend): replace raw fetch with event.fetch in admin/enrich routes (handleFetch bypass) #465
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
The audit found four
+page.server.tsfiles that use the globalfetchinstead of destructuringfetchfrom the load/action event. This bypasses thehandleFetchhook infrontend/src/hooks.server.ts:63-106, which is responsible for injecting theAuthorization: <auth_token>header on outbound API calls.Two failure modes:
auth_tokencookie's bearer credential, the backend returns 401, and the user sees a confusing error.env.API_INTERNAL_URLand works (because the backend is on the same network), then it's effectively running as a server-level identity, not the user's identity. That's a privilege-escalation surface — depending on the endpoint, it could leak admin-only data to a non-admin user.The login action's raw
fetchis intentional and stays (it constructs the Basic header from the form, by design). All others should useevent.fetch.Approach
Each affected file already destructures
cookiesandrequestfrom the action event. Addfetch:handleFetchinjects the auth header automatically;createApiClient(fetch)gives a typed wrapper from$lib/shared/api.server. This also makes the call testable in vitest-browser.Critical files
frontend/src/routes/enrich/[id]/+page.server.ts(lines 75, 101)frontend/src/routes/admin/invites/+page.server.ts(lines 23, 50, 77)frontend/src/routes/admin/users/[id]/+page.server.ts(line 48)The login flow at
frontend/src/routes/login/+page.server.tsis explicitly out of scope — its rawfetchis intentional and documented.Verification
grep -rE "fetch\(\?\${.*API|fetch\(env\." frontend/src/routes` — should show only the login action.Acceptance criteria
fetch(\...${env.API_*}` ...)patterns in+page.server.tsfiles exceptroutes/login/+page.server.ts`.event.fetchandcreateApiClient.npm run check).Effort
S — 1 day, mostly retesting the affected user flows.
Risk if not addressed
Privilege confusion on admin endpoints; silent 401s for users on enrich endpoints.
Tracked in audit doc as F-15 (High).
🏛️ Markus Keller — Application Architect
Observations
The issue is well-scoped and the root cause is correctly diagnosed. The
handleFetchhook inhooks.server.tsis the canonical auth injection point for SvelteKit server-side API calls — it exists precisely so that individual+page.server.tsfiles do not have to manage auth headers manually. The three affected files bypass this layer by calling globalfetchdirectly.After reading the code, the files fall into two distinct cases:
Case A — full bypass with
env.API_INTERNAL_URLconstruction (admin/invites/+page.server.ts, and partiallyenrich/[id]/+page.server.ts): These build the URL from the env var and call globalfetch, sohandleFetchnever fires. The auth header is absent entirely — the backend sees an unauthenticated request.Case B — relative-path call (
admin/users/[id]/+page.server.tsline 48):fetch('/api/users/${id}', ...)uses a relative URL. In SvelteKit's server context, this does route throughhandleFetch, but the hook'sisApicheck (request.url.includes('/api/')) depends on the fully-resolved URL. Verify whether relative-URL calls in server actions actually triggerhandleFetch— if not, this is the same class A bug with different surface.Multipart note: The
enrichsave actions use rawfetchwithformDataas body. This is the documented exception pattern for multipart uploads (the typed client does not support multipart natively), but the fix is to still useevent.fetch— just notcreateApiClient. The distinction between "useevent.fetchwith a rawRequest" and "usecreateApiClient(fetch)" matters here.The issue's proposed fix is architecturally correct: destructure
fetchfrom the event, pass it tocreateApiClientwhere the endpoint is typed, or pass it directly for multipart calls. Either way, the auth injection happens in one place.Recommendations
createApiClient(fetch)pattern already used in theloadfunctions anddeleteaction of the same files is the right model.event.fetchdirectly (notcreateApiClient) since the typed client does not support multipart bodies. The invocation becomesfetch(\${baseUrl}/api/documents/${params.id}`, { method: 'PUT', body: formData })wherefetch` is destructured from the action event — not the global.admin/users/[id]relative-path case before closing: test whetherhandleFetchfires on relative-URL calls in action context; if it does, the bug is less severe than stated but the refactor is still worthwhile for consistency.userGrouphook inhooks.server.tsline 48 also calls globalfetchfor the/api/users/mebootstrap call. That call correctly setsAuthorizationmanually, so it is not broken — but it is worth noting as a pattern that should not be replicated.👨💻 Felix Brandt — Fullstack Developer
Observations
Having read all three affected files, the pattern inconsistency is clear. The same files are already partially migrated: in
enrich/[id]/+page.server.ts, theloadfunction andskipaction correctly usecreateApiClient(fetch)from the event — butsaveandsaveAndReviewat lines 72–85 and 97–111 drop back to globalfetchwith a manually constructed URL. This is the most confusing case because the two styles coexist in a single file.In
admin/invites/+page.server.ts, all three callsites (load, create, revoke) use raw fetch withenv.API_INTERNAL_URL.event.fetchis already destructured — it just isn't used for the actual API calls.In
admin/users/[id]/+page.server.ts, theupdateaction at line 48 uses a relative-URL raw fetch, while thedeleteaction immediately below usescreateApiClient. Again, two styles in one file.Specific clean code concern: the
updateaction inadmin/users/[id]/+page.server.tsdoes its own JSON error parsing inline (lines 55–62) rather than using the project'sparseBackendError()/getErrorMessage()pattern. This should be fixed in the same PR since it is in the same function being touched.Multipart caveat: the
CONTRIBUTING.mdnote says "For multipart/form-data, bypass the typed client and use rawfetch" — this was likely written meaningevent.fetch, not globalfetch. The fix for the enrich save actions isevent.fetchwith the raw request, notcreateApiClient.Recommendations
enrich/[id]/+page.server.tssaveandsaveAndReview: replace globalfetch(${baseUrl}/api/...)withevent.fetch(${baseUrl}/api/...)— keep raw fetch for the multipart body, just change thefetchreference.baseUrlcomes fromenv.API_INTERNAL_URL(already imported).admin/invites/+page.server.tsload, create, revoke: all three can switch tocreateApiClient(fetch)— the endpoints are JSON, no multipart needed. Remove theapiUrlvariable and the manual URL construction entirely.admin/users/[id]/+page.server.tsupdate: switch tocreateApiClient(fetch).PUT(...)and replace the inline error JSON parsing withparseBackendError+getErrorMessageto match the project pattern.CONTRIBUTING.mdmultipart note to clarify "bypasscreateApiClientbut always useevent.fetch, never globalfetch." One sentence is enough.login/+page.server.ts— its raw fetch is intentional and already has an explanatory comment.The fix is mechanical and low-risk. All three files already import the correct utilities; this is wiring, not logic.
🔒 Nora "NullX" Steiner — Security Engineer
Observations
This is a real vulnerability, not a smell. I reviewed the three files and can confirm two distinct failure modes depending on which endpoint is called:
Failure Mode 1 — Missing authentication (enrich and invites)
enrich/[id]/+page.server.tssave/saveAndReviewandadmin/invites/+page.server.tsall send requests to${env.API_INTERNAL_URL}/api/...using globalfetch. ThehandleFetchhook never fires for globalfetchcalls — it only intercepts calls made through the SvelteKit-providedevent.fetch. The backend receives a request with noAuthorizationheader. Whether this results in a 401 or a pass depends entirely on how the backend'sSecurityConfigis configured for those endpoints.If the backend's
@RequirePermissionAOP check runs (which it should on PUT/api/documents/{id}and POST/DELETE/api/invites), it will 401 or 403. But the frontend action then catches that error and returns it as a user-facing form error, not a redirect to login — so the user sees a confusing error message rather than being told they're unauthenticated. This is the "silent un-authentication" mode described in the issue.Failure Mode 2 — Potential privilege escalation (admin/users update)
admin/users/[id]/+page.server.tsline 48:fetch('/api/users/${params.id}', ...)uses a relative URL. In a SvelteKit server context, whether this triggershandleFetchdepends on the SvelteKit version and whether the URL is resolved to an absolute form beforehandleFetchintercepts it. IfhandleFetchdoes NOT fire here, the request goes to the backend without the user's auth cookie — but since it's running server-side on the same network as the backend, it might succeed if the backend accepts unauthenticated PUT requests to/api/users/{id}. That would be a privilege escalation: any authenticated frontend user could modify any other user's record if the backend misconfigures@RequirePermission.CWE: CWE-306 (Missing Authentication for Critical Function) — partial, because the backend has its own auth layer, but the defense-in-depth at the frontend layer is broken.
Important nuance: The backend
@RequirePermissionannotations are the true security boundary. This frontend bug reduces defense-in-depth and creates a confusing UX, but it does not bypass backend enforcement. The risk level stated in the issue (High / F-15) is appropriate — this is a defense-in-depth failure with a UX impact, and the user-edit endpoint could be an escalation surface if backend auth is not correctly configured on that specific endpoint.Recommendations
event.fetchthroughout. This is not optional; it is a security fix.@RequirePermissionon PUT/api/users/{id}: confirm the endpoint has@RequirePermission(Permission.ADMIN_USER)or equivalent. The frontend fix removes the bypass possibility; the backend annotation closes the door permanently.// event.fetch is required here — global fetch bypasses handleFetch auth injection. This makes the correct pattern explicit for the next developer.handleFetchhook as the sole auth layer — it is defense-in-depth. Backend@RequirePermissionremains the primary control. The issue is that removing a defense layer is always a regression, regardless of other layers.🧪 Sara Holt — QA Engineer
Observations
The issue's verification section gives a grep command and three manual smoke tests, but no automated test specification. Given that this is a security-adjacent regression fix, manual smoke is insufficient — the next time someone edits these files, there is no automated check that prevents the same pattern from creeping back.
After reading the code, I can see the test approach needs to account for two layers:
event.fetch? Yes, because+page.server.tsaction functions are plain TypeScript that accept the event object.The issue also mentions "New test: vitest-browser test for the enrich action's happy path mocking the typed API client." A vitest-browser test is for component rendering — it is the wrong layer for server action testing. Server actions are tested with Vitest in Node environment by importing and calling the action function directly.
The verification grep (
grep -rE "fetch\(\?${.*API|fetch(env."`) is a good sanity check but will not catch future regressions automatically. This should be a CI lint rule or a test assertion.Recommendations
fetchand verifies the mock was called (provingevent.fetchwas used, not globalfetch). Example pattern:/enrich/[id], submit the form, verify no 401 appears in the network tab or in the page response. This catches the auth header being missing at the integration level.glob+readFileSync) that scansfrontend/src/routesfor the rawfetch(${env.APIpattern excludinglogin— this is the regression sentinel the grep currently provides manually.npm run test(Node/Vitest) suite.event.fetchreturns a 401 response, the action returns the appropriatefail()rather than throwing or swallowing the error.📋 Elicit — Requirements Engineer
Observations
The issue is well-written by this codebase's standards: it names the exact affected lines, describes two concrete failure modes, gives a before/after code sample, and defines acceptance criteria. It passes the Definition of Ready check.
One gap: the acceptance criteria are written from the code perspective, not from the user's perspective. This matters for the smoke-test step and for knowing when this is truly done.
Specific gaps I found after reading the code:
The issue says "six raw fetches" in the header listing but the actual count across the three files is: enrich (2) + invites (3) + admin/users (1) = 6. Confirmed correct.
The acceptance criterion "No
fetch(\...${env.API_}` ...)patterns" would NOT catch theadmin/users/[id]/+page.server.tsline 48 pattern, which uses a relative URLfetch('/api/users/${params.id}', ...)— not anenv.API_` interpolation. The grep in the verification section would also miss this case."Existing enrich/admin happy paths still work (manual smoke + existing E2E if any)" — there are no existing E2E tests for these specific flows based on the directory structure. The criterion should explicitly acknowledge this and require a new Playwright smoke test rather than relying on "existing E2E if any."
The issue states effort as "S — 1 day, mostly retesting." Given that this is a security-adjacent fix with no existing automated test coverage for the affected flows, the retesting burden is real. The estimate is reasonable if the PR author plans to add the regression tests during the same session.
Recommendations
env.APIinterpolation grep, and (2) separately verify thatadmin/users/[id]/+page.server.tsupdate action no longer contains a rawfetch(call.CONTRIBUTING.mdmultipart note ambiguity (does "bypass typed client" mean "bypass event.fetch" too?) should be resolved in this PR to prevent the same misreading from recurring. Add an AC item: "CONTRIBUTING.md multipart fetch note clarified to specifyevent.fetchmust still be used."⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
This is a frontend code fix, so the infrastructure surface is narrow. A few things worth noting from my angle:
The
env.API_INTERNAL_URLpattern: The raw-fetch calls useenv.API_INTERNAL_URL || 'http://localhost:8080'as the base URL. This is the same pattern used inhooks.server.tsandapi.server.ts. The env var is set in the Docker Compose environment for the frontend service. After the fix, this env var is still needed inapi.server.ts(whichcreateApiClientuses) — so no environment change is required.Internal network auth concern: The issue flags "privilege confusion" where a call to
env.API_INTERNAL_URLfrom the server side might succeed without auth because the backend and frontend container are on the same Docker network. In the currentdocker-compose.yml, the backend does not have--spring.security.require-ssl=trueor similar network-level isolation. Backend@RequirePermissionis the only control. This is correct for the current setup, but worth noting.The
hooks.server.tsglobal fetch inuserGroup: The hook at line 47 calls globalfetchfor/api/users/mewith an explicitAuthorizationheader. This is a slightly different case — the hook runs beforehandleFetchis in scope for that particular call. The pattern is correct (manual auth header), just flagged for awareness that it is the same-looking code for a different reason.No Compose or CI changes needed for this fix — it is pure TypeScript.
Recommendations
API_INTERNAL_URLis correctly set in all environments (dev Compose, CI Compose overlay, production) before merging. The fix routes all calls throughcreateApiClientwhich reads this env var — a missing value falls back tolocalhost:8080, which is fine in dev but would fail in CI if the backend runs on a different hostname. This is already true for theloadfunctions that usecreateApiClient, so no new risk is introduced, but it is worth double-checking the CI compose overlay has this var.npm run dev) to confirm the auth header injection works correctly when the frontend and backend are on separate containers and theAPI_INTERNAL_URLis the container hostname.🎨 Leonie Voss — UI/UX & Accessibility
Observations
This fix is infrastructure — no visual changes, no component changes. But there is a UX consequence worth naming explicitly.
Current UX failure mode: When the enrich
saveorsaveAndReviewaction fails silently due to missing auth, the user sees a form error. The error message comes fromgetErrorMessage(backendError?.code)— which is correct — but the upstream cause (the backend returning 401 because the auth header was absent) maps to a generic or confusing error code. The user who just filled out the enrichment form sees an opaque error and does not know whether to try again or log in again.Post-fix UX: After switching to
event.fetch, a missing auth token will causehandleFetchto returnnew Response('Unauthorized', { status: 401 })before the backend is even called. This is still an error, but at least it is the correct, expected error flow. TheparseBackendErrorcall will fail to parse this plain-text response (no JSON body), returningnull, andgetErrorMessage(undefined)will show the generic internal error message. This is still not a great user experience — the user should see "session expired, please log in again."The 401 from
handleFetch: The hook returnsnew Response('Unauthorized', { status: 401 })with a plain text body, not the JSON{ code: 'UNAUTHORIZED' }structure thatparseBackendErrorexpects. So after the fix, a session-expired user submitting the enrich form will see the generic error, not a targeted "please log in" message.Recommendations
parseBackendErroralready handles null gracefully. The improvement in reliability is the right priority.handleFetch's 401 response return a JSON body{ code: 'UNAUTHORIZED' }so thatparseBackendErrorcan map it to the i18n string for "session expired." This would give users a clear message to log in again rather than a generic error. This is a Minor UX improvement, not a blocker for this fix.getErrorMessagecorrectly.Decision Queue
Consolidated open decisions raised by the review. No verdicts — these need a call before or during implementation.
Theme 1 — Multipart + event.fetch scope
Raised by: Markus, Felix
The enrich
save/saveAndReviewactions must use raw fetch (notcreateApiClient) because the typed client does not support multipart bodies. The question is how explicit this exception should be documented:event.fetchfor multipart calls, and add a one-sentence clarification toCONTRIBUTING.md("for multipart, bypasscreateApiClientbut never bypassevent.fetch").createApiClientis not used.Both options fix the bug. Option B adds noise in the code; Option A keeps the explanation in the doc. The project style ("intentional choices need no doc comment") suggests Option A — but this is a subtle distinction that has already confused at least one developer once, so the tradeoff is real.
Theme 2 — SvelteKit relative-URL fetch and handleFetch
Raised by: Markus, Nora
admin/users/[id]/+page.server.tsline 48 usesfetch('/api/users/${params.id}', ...)with a relative URL. The question is: does SvelteKit route relative-URL server-action fetches throughhandleFetch?The answer determines how severe this specific callsite is:
handleFetchfires and the auth header IS injected — the bug is less severe (UX annoyance, not auth bypass), but the refactor is still worth doing for consistency.This can be determined empirically: add a
console.logtohandleFetchand submit the user-edit form in dev. This must be verified before merging, not assumed.Theme 3 — handleFetch 401 response format (follow-up scope)
Raised by: Leonie
When
handleFetchreturns a 401 (no auth token in cookie), it returnsnew Response('Unauthorized', { status: 401 })with a plain-text body.parseBackendErrorcannot parse this and falls back to the generic error message — the user sees "Something went wrong" instead of "Session expired, please log in."Decision: should fixing
handleFetchto return{ code: 'UNAUTHORIZED' }JSON be in scope for this issue, or tracked as a separate follow-up?Recommendation: separate issue — it is a UX improvement, not a security fix, and adding it here risks scope creep on a P1 security item. But it should be filed before this PR closes so it is not forgotten.