-
Notifications
You must be signed in to change notification settings - Fork 5
feat(logging): add HTTP request/response interceptor - #2227
Draft
manzke wants to merge 6 commits into
Draft
Conversation
Some problems only show up in the raw traffic: a provider that rejects a header we thought we were sending, a proxy that rewrites a path, an integration that answers 200 with an error body. Neither the audit log (mutating, authenticated requests only) nor OpenTelemetry (spans, not wire data) shows that, so seeing it took a code change. Admin -> Logging -> HTTP Interceptor now captures it on both sides. Core (server/utils/httpInterceptor.js): - isInboundEnabled/isOutboundEnabled predicates, memoised on the identity of the raw config object so the disabled path is an identity check plus a boolean read - recordInbound/recordOutbound emit through logger.debug under component HttpInterceptor, stamped with the requestId from the per-request context so an outbound provider call joins to the inbound request that caused it - consolidated redactUrl/redactHeaders/redactBody, implementing the contract the orphaned logRedactor.README.md described for a module that never existed Capture points: - new inbound middleware, registered after express.json()/cookieParser() and before the rate limiters; static assets skipped, /api/health denied by default - httpFetch() in utils/httpConfig.js, the chokepoint for every outbound call except the MCP/OpenAPI transport - safeFetch() in services/mcp/safeFetch.js, which needs its own hook because it keeps the socket pinned to the SSRF-vetted address Guards: - streamed responses are recorded with status/headers/timing but their bodies are never buffered; response bodies are only peeked off a detached clone, so a peek can never stall the branch the caller is reading - bodies are capped (8 KB default) with the drop visible in the record - credentials are masked in URLs, headers and bodies; header names, auth schemes, Set-Cookie attributes and LLM token counts are kept, since those are usually what you came to look at - raw mode disables redaction and the cap for the case where the redaction hides the value being chased - capture auto-disables 60 minutes after being switched on, announced at info level, so an interceptor left on in production turns itself off Config lands under logging.http (schema, defaults, migration V084), reload is followed on every worker via configReloadHooks, and the admin page warns when capture is on while the log level is above debug. Two fixes fell out of touching the outbound paths: - PromptNodeExecutor's grounding-link resolution used raw fetch() and so ignored the platform proxy/SSL configuration - safeFetch wrapped the undici capability probe and the request in one catch, so a genuine network error was retried through the fallback client and surfaced a misleading message Closes #2224 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQRZBndxRcZhmh7WoKPEPe
@github-actions
github-actions
Bot
added
bug
Something isn't working
documentation
Improvements or additions to documentation
enhancement
New feature or request
dependencies
Pull requests that update a dependency file
backend
auth
api
i18n
frontend
admin
testing
ui
labels
Aug 25, 2026
CodeQL re-reported js/request-forgery at the undici fetch in safeFetch after this branch moved the call into an arrow function — the sink is unchanged from main, but the new location gets a new fingerprint. A user-influenced URL reaching that call is the premise of safeFetch, not a bug: resolveAndCheck() resolves the hostname once and refuses private and internal addresses unless explicitly allow-listed, and the dispatcher pins the socket to the vetted address so re-resolution cannot swing to localhost. The guard is not something CodeQL can model. Suppressed inline with the reasoning, matching the convention already used at the safeFetch call site in routes/admin/tools.js. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQRZBndxRcZhmh7WoKPEPe
Hooking the interceptor into the tail of httpFetch put two concerns in one function and rewrote the line that performs the request. Separate them: proxiedFetch applies the proxy/SSL configuration and sends, httpFetch wraps it for the wire log. That matches how services/mcp/safeFetch.js already hands its own transport to interceptedFetch, and it leaves the request line itself untouched. A side effect worth stating plainly: CodeQL's pre-existing js/request-forgery alert on that line is no longer attributed to this branch's diff, because the line is no longer part of it. The alert is unchanged on main and stays open there — this does not suppress it, and the audit of httpFetch's callers that would actually answer it still belongs in its own change. Two behaviours moved with the split, so both are now covered: scheme validation happens inside proxiedFetch (a bad scheme still reaches the caller as the same error, and is now recorded as a failed outbound call), and the `lookup` option still reaches the agent rather than node-fetch, so the workflow SSRF guard keeps its DNS pinning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQRZBndxRcZhmh7WoKPEPe
Mirrors what utils/httpConfig.js now does: pinnedFetch keeps the SSRF guard and performs the request, safeFetch wraps it for the wire log. pinnedFetch's body is unchanged from main, so the guarantee it makes is easy to check against what was reviewed before. This replaces the inline `// codeql[js/request-forgery]` added in the previous commit, which did nothing. This repository has no CodeQL workflow or config — code scanning runs through GitHub's default setup, which does not honour inline suppression comments. The CodeQL run on that commit still reported both alerts with the comment in place, so the two pre-existing `// codeql[...]` comments elsewhere in the tree are documentation rather than working suppressions. Reverting to main's body means dropping the try/catch fix that was bundled in here (a rejection from the undici request was treated as "undici unavailable" and silently retried through the fallback shim). That bug predates this branch and deserves its own review rather than riding along in a logging change; its changelog entry is removed accordingly. Both halves are covered: the SSRF guard still refuses a private address that was not allow-listed, an unsupported protocol still throws, and both refusals now appear in the wire log instead of going silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQRZBndxRcZhmh7WoKPEPe
test-quick failed on CI with `connect ECONNREFUSED ::1:37429`. The check pointed safeFetch at `localhost` while its test server was bound to 127.0.0.1; on a dual-stack runner `localhost` resolves to ::1 first, so the connection went nowhere. It passed locally only because this environment maps localhost to 127.0.0.1 alone. Use an IP literal instead. dns.lookup resolves literals as-is, so the SSRF guard is still exercised end to end — the refusal for a non-allow-listed private address, the allow-listed success, and both appearing in the wire log. Real DNS resolution stays covered by the pinned-lookup check, which overrides resolution outright and was already family-independent. Verified by emulating the runner: with dns.lookup patched to answer ::1 for localhost, the previous form fails on the address family and the suite passes with this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQRZBndxRcZhmh7WoKPEPe
Main absorbed the visual loop containers work (#2226), which touched the 5.5.0 changelog, the i18n bundles and the jest setup — the same files this branch changes. The merge is textually clean, and bringing it in means the PR's green CI reflects the current base rather than the one it was pushed against. Verified on the merged tree: both sets of 5.5.0 changelog entries survive, both i18n bundles are still valid JSON, test:quick kept this branch's test:http-interceptor entry, and 593 tests across 47 suites pass along with all 34 interceptor checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQRZBndxRcZhmh7WoKPEPe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
Closes #2224.
Some problems only show up in the raw traffic: a provider that rejects a header we thought we were sending, a proxy that rewrites a path, an integration that answers
200with an error body. Neither the audit log (mutating, authenticated requests only) nor OpenTelemetry (spans, not wire data) shows that, so seeing it took a code change. Admin → Logging → HTTP Interceptor now captures it on both sides.Built to the plan in the issue, with the open questions answered as agreed in this comment:
logger.debugas the sink, no live tail, raw mode available, no SSE-body capture yet, global on/off, static assets and/api/healthexcluded.What was added
Core —
server/utils/httpInterceptor.jsisInboundEnabled(req)/isOutboundEnabled(url)predicates, memoised on the identity of the raw config object (configCachehands back the same object until a reload), so the disabled path is an identity check plus a boolean read.recordInbound/recordOutboundemit throughlogger.debugunder componentHttpInterceptor, each stamped with therequestIdfrom the per-requestAsyncLocalStoragecontext — so an outbound provider call joins to the inbound/api/chatrequest that caused it:jq 'select(.component == "HttpInterceptor" and .requestId == "...")' logs/app.loginterceptedFetch(fetchFn, ...)— one wrapper both outbound transports hand themselves to.redactUrl/redactHeaders/redactBody, implementing the contract thatserver/utils/logRedactor.README.mddescribed for a module which never existed. That orphaned doc is removed and the real functions are documented indocs/logging.md.Capture points. Each outbound transport is split into a half that performs the request and a half that observes it, so the two concerns stay separate and the transport keeps the body it had on
main:server/middleware/httpInterceptor.jsexpress.json()/cookieParser()soreq.bodyis parsed, and before the rate limiters so a 429 is still recorded.httpFetch()→proxiedFetch()inserver/utils/httpConfig.jssafeFetch()→pinnedFetch()inserver/services/mcp/safeFetch.jshttpFetchto keep the socket pinned to the SSRF-vetted address.Config —
logging.http(schema inplatformConfigSchema.js, defaults inserver/defaults/config/platform.json, migrationV084). Everything off by default. Reload is followed on every worker throughconfigReloadHooks.Admin UI — new section in
AdminLoggingPage.jsx, saving through the existingPUT /api/admin/logging/config. It warns when capture is on while the log level is abovedebug, and surfaces the raw-mode warning only once bodies are enabled.Guards
The risky parts of this feature are memory and privacy, so:
/api/chat,/api/inference, agent and workflow runs are recorded with status, headers and timing; their bodies are marked[STREAM]. Binary content types are skipped, and multipart/typed-array request bodies are named ([FORM-DATA],[BINARY n bytes]) rather than serialised.clone()tees the body, andpipestalls the source as soon as either branch fills — so awaiting a peek before returning the response would deadlock both. The peek runs detached with a timeout, and the record is written when it resolves....[TRUNCATED 8192 of 41003 bytes].Set-Cookieattributes and LLM token counts (maxTokens,promptTokens,totalTokens) are deliberately kept — those are usually what you came to look at.0to disable), announced atinfolevel. Enabling capture also writes oneinfoline naming what was turned on, including when it was enabled by hand-editingplatform.json— the one case the admin UI cannot warn about.One fix that fell out of touching the outbound paths
Documented in
docs/releases/5.5.0/fixes.md:PromptNodeExecutor's Google grounding-link resolution used rawfetch(), so it ignored the platform proxy/NO_PROXY/SSL configuration that every other outbound call honours. On deployments requiring an egress proxy the resolution silently failed.I had also bundled in a
safeFetchfix (a rejection from the undici request was treated as "undici unavailable" and silently retried through the fallback shim, surfacing a misleading error). That is no longer in this PR — it predates this branch and deserves its own review rather than riding along in a logging change. Happy to open it separately.A note on the CodeQL alerts
Both
js/request-forgeryalerts that CodeQL raised on this PR were pre-existing sinks onmainthat the first version of this diff had relocated, so code scanning attributed them to the changed lines. They are resolved by the transport/observation split above, which leavesreturn nodeFetch(url, enhanced);andreturn await globalThis.fetch(url, { ...init, dispatcher });as untouched context.To be explicit: nothing is suppressed. Those alerts are unchanged on
mainand stay open on the branch. Worth knowing separately — this repository has no CodeQL workflow or config, so code scanning runs through GitHub's default setup, which does not honour inline// codeql[...]comments. The two such comments already in the tree (httpConfig.js:324,routes/admin/tools.js:1027) are therefore documentation for readers rather than working suppressions. See the review threads for the reasoning and a proposed follow-up audit ofhttpFetch's callers.Verification
server/tests/httpInterceptor.test.js— 34 checks, wired intonpm run test:quick: redaction coverage (URL/header/body/cookie/XML/form), themaxTokens-is-not-a-token case, body cap and the truncation note, the >256 KB pattern-redaction path,maxBodyBytes: 0, raw mode end to end, allow/denylist selection for both directions,/api/healthexclusion and path-segment prefix matching, auto-disable expiry and0= never, zero output when disabled, content-type gating, the inbound middleware against a realhttp.Server(bodies, SSE marked not buffered, static assets skipped), outbound success/failure/stream paths, and inbound↔outboundrequestIdcorrelation.httpFetchstill rejects a non-http(s) scheme and still applies the SSRF guard's pinned DNSlookupto the agent (without it,HttpNodeExecutorwould silently lose its pinning);safeFetchstill refuses a non-allow-listed private address and still throws on an unsupported protocol, and both refusals now appear in the wire log.npm run test:quick— 430 unit tests + all cluster/adapter suites pass.npm run test:integration:ci— 91 pass, 6 skipped.npm run lint:fix && npm run format:fixclean; no new ESLint findings (the one remainingAdminLoggingPage.jsxa11y warning is present atHEADtoo).V084applies and is idempotent./api/healthexcluded by the denylist,?key=redacted in the recorded URL,password/apiKeymasked in the request body,Cookie/Set-Cookiemasked with attributes intact, and response bodies captured with timing. Sample record:{ "component": "HttpInterceptor", "direction": "inbound", "requestId": "c32c469b-2753-4a80-95ec-24fba1c11c89", "method": "POST", "url": "/api/auth/local/login", "status": 200, "durationMs": 340.2, "requestHeaders": { "cookie": "authToken=[REDACTED]", "content-type": "application/json" }, "responseHeaders": { "set-cookie": "authToken=[REDACTED]; Path=/; SameSite=Lax; HttpOnly" }, "requestBody": "{\"username\":\"admin\",\"password\":\"pass...[REDACTED]\",\"apiKey\":\"sk-s...[REDACTED]\"}" }/admin/logging): the section renders with no console errors, all nine toggles work, the level warning appears while the level is abovedebug, and a save round-trips toplatform.jsonwith the comma-separated method and host lists parsed correctly (["post","put"],["api.openai.com","*.anthropic.com"]).🤖 Generated with Claude Code
https://claude.ai/code/session_01KQRZBndxRcZhmh7WoKPEPe