Skip to content

Navigation Menu

Sign in
Sign up

feat(logging): add HTTP request/response interceptor - #2227

Draft
manzke wants to merge 6 commits into
main from
claude/github-issue-2224-yulwu1
Draft

feat(logging): add HTTP request/response interceptor #2227
manzke wants to merge 6 commits into
main from
claude/github-issue-2224-yulwu1

Conversation

@manzke

@manzke manzke commented Aug 25, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

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 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.

Built to the plan in the issue, with the open questions answered as agreed in this comment: logger.debug as the sink, no live tail, raw mode available, no SSE-body capture yet, global on/off, static assets and /api/health excluded.

What was added

Core — server/utils/httpInterceptor.js

  • isInboundEnabled(req) / isOutboundEnabled(url) predicates, memoised on the identity of the raw config object (configCache hands back the same object until a reload), so the disabled path is an identity check plus a boolean read.
  • recordInbound / recordOutbound emit through logger.debug under component HttpInterceptor, each stamped with the requestId from the per-request AsyncLocalStorage context — so an outbound provider call joins to the inbound /api/chat request that caused it:
    jq 'select(.component == "HttpInterceptor" and .requestId == "...")' logs/app.log
  • interceptedFetch(fetchFn, ...) — one wrapper both outbound transports hand themselves to.
  • Consolidated redactUrl / redactHeaders / redactBody, implementing the contract that server/utils/logRedactor.README.md described for a module which never existed. That orphaned doc is removed and the real functions are documented in docs/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:

Where Covers
server/middleware/httpInterceptor.js Every request Express serves. Registered after express.json()/cookieParser() so req.body is parsed, and before the rate limiters so a 429 is still recorded.
httpFetch()proxiedFetch() in server/utils/httpConfig.js Every outbound call in the server except the MCP/OpenAPI transport — LLM providers, iFinder, Jira, Nextcloud, Google Drive, web search, model discovery, JWKS.
safeFetch()pinnedFetch() in server/services/mcp/safeFetch.js MCP servers and the OpenAPI tool runner. Needs its own hook: it deliberately bypasses httpFetch to keep the socket pinned to the SSRF-vetted address.

Configlogging.http (schema in platformConfigSchema.js, defaults in server/defaults/config/platform.json, migration V084). Everything off by default. Reload is followed on every worker through configReloadHooks.

Admin UI — new section in AdminLoggingPage.jsx, saving through the existing PUT /api/admin/logging/config. It warns when capture is on while the log level is above debug, and surfaces the raw-mode warning only once bodies are enabled.

Guards

The risky parts of this feature are memory and privacy, so:

  • Streams are never buffered. /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.
  • Outbound response bodies are peeked off a detached clone. clone() tees the body, and pipe stalls 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.
  • Bodies are capped (8 KB default) with the drop visible in the record: ...[TRUNCATED 8192 of 41003 bytes].
  • Credentials are masked in URLs, headers and bodies. Header names, auth schemes, Set-Cookie attributes and LLM token counts (maxTokens, promptTokens, totalTokens) are deliberately kept — 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. It warns accordingly.
  • Auto-disable stops capture 60 minutes after it was switched on (configurable, 0 to disable), announced at info level. Enabling capture also writes one info line naming what was turned on, including when it was enabled by hand-editing platform.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 raw fetch(), 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 safeFetch fix (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-forgery alerts that CodeQL raised on this PR were pre-existing sinks on main that 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 leaves return nodeFetch(url, enhanced); and return await globalThis.fetch(url, { ...init, dispatcher }); as untouched context.

To be explicit: nothing is suppressed. Those alerts are unchanged on main and 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 of httpFetch's callers.

Verification

  • server/tests/httpInterceptor.test.js — 34 checks, wired into npm run test:quick: redaction coverage (URL/header/body/cookie/XML/form), the maxTokens-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/health exclusion and path-segment prefix matching, auto-disable expiry and 0 = never, zero output when disabled, content-type gating, the inbound middleware against a real http.Server (bodies, SSE marked not buffered, static assets skipped), outbound success/failure/stream paths, and inbound↔outbound requestId correlation.
  • Both transport splits are covered on the behaviours that moved with them: httpFetch still rejects a non-http(s) scheme and still applies the SSRF guard's pinned DNS lookup to the agent (without it, HttpNodeExecutor would silently lose its pinning); safeFetch still 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:fix clean; no new ESLint findings (the one remaining AdminLoggingPage.jsx a11y warning is present at HEAD too).
  • Server boots clean; V084 applies and is idempotent.
  • Verified live against a running server: enabled capture through the admin API, confirmed /api/health excluded by the denylist, ?key= redacted in the recorded URL, password/apiKey masked in the request body, Cookie/Set-Cookie masked 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]\"}"
    }
  • Verified the admin section in a browser (login → /admin/logging): the section renders with no console errors, all nine toggles work, the level warning appears while the level is above debug, and a save round-trips to platform.json with 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

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
Comment thread server/services/mcp/safeFetch.js Fixed
Comment thread server/utils/httpConfig.js Fixed
claude added 5 commits August 25, 2026 16:47
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 
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

1 more reviewer
@github-advanced-security github-advanced-security[bot] github-advanced-security[bot] left review comments
Reviewers whose approvals may not affect merge requirements

Assignees

No one assigned

Labels

admin api auth backend bug Something isn't working dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation enhancement New feature or request frontend i18n testing ui

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

http request and response interceptor

AltStyle によって変換されたページ (->オリジナル) /