[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests - #285344
Merged
jeramysoucy merged 10 commits intoAug 28, 2026
Conversation
…ests For HTTP/2 requests, `req.socket` is a stream-level Proxy whose `getPrototypeOf` trap branches on `stream[kSession]`. When a stream is destroyed (RST_STREAM, browser navigation, AbortController cancel), Node clears that reference and the proxy falls back from TLSSocket to Http2Stream — breaking `instanceof TLSSocket` and causing KibanaSocket to return `authorized: undefined` and `getPeerCertificate(): null` even on live, authorized connections. This patch captures the session-level socket at KibanaSocket construction time instead. The session socket resolves to the underlying TLSSocket and remains stable for the lifetime of the TCP connection — the correct semantic, since client certificates are a property of the connection, not of individual streams. Resolves the root cause of kibana#258232 and also fixes the silent loss of `remoteAddress` in audit logs for destroyed HTTP/2 streams. Companion to PR elastic#285153 (pki.ts symptom-level guard). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
3 tasks
Relocates the helper from request.ts to socket.ts, next to KibanaSocket. It was exported from request.ts only so tests could reach it, while its tests already lived in socket.test.ts. Beyond convention, this removes a real side effect: request.ts calls patchRequest() at module load, so importing it from socket.test.ts dragged that global patch plus hapi, rxjs and uuid into a test that needs only net/tls. Also replaces the `as any` at the call site with proper typing. Node's own definitions support it — Http2ServerRequest extends stream.Readable rather than IncomingMessage, so the two are structurally distinct and a `'stream' in req` type guard narrows the union with no cast. Drops the now-unused `net` import from request.ts. Fixes the Prettier violation that failed the Linting and Local Check CI steps: the old single-line signature was 113 chars against a 100-char print width. No behavioural change — the four tests encoding the contract (HTTP/1.1 → req.socket; HTTP/2 → session socket; destroyed session → fallback; ERR_HTTP2_SOCKET_UNBOUND → fallback) pass unmodified. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
Pinging @elastic/kibana-core (Team:Core) |
hammad-nasir-elastic
approved these changes
Aug 25, 2026
hammad-nasir-elastic
left a comment
Contributor
There was a problem hiding this comment.
Code review only, LGTM
The pki_stress spec asserted that RST_STREAM degrades the parked request's socket (`peerCertificateNull === true`, `authorized !== true`). That was the symptom `resolveRawSocket` removes: KibanaSocket now captures the session-level TLSSocket, which outlives destruction of any single HTTP/2 stream, so the assertion can never be satisfied and the step timed out after 120s. Invert it to assert the socket survives the RST, which is the behavior we actually want — under PKI the client certificate belongs to the TLS connection, not to an individual stream. Inverting alone would let the step pass even if the RST never reached the server, so track `request.events.aborted$` on the pre-auth hold and require it alongside `continuedAfterHold === false`. That pins the cancellation to the window where the hold is still parked inside PKI authenticate, which is the window that invalidated the shared ES token in elastic#258232. Also add a unit lock in socket.test.ts: a KibanaSocket built via resolveRawSocket from a live HTTP/2 request keeps reporting the peer certificate after stream.session is cleared. No production code changes — pki.ts keeps its degraded-socket guard, which still covers HTTP/1.1 closed sockets, streams destroyed before the request was constructed, and fake sockets. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The spec edit shifted the test declaration and changed the git object hash of the `api/` directory, which the manifest pins. Regenerated via `node scripts/scout update-test-config-manifests`. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Contributor
💛 Build succeeded, but was flaky
Failed CI StepsMetrics [docs]
Test Failures
History
|
Contributor
|
Starting backport for target branches: 9.5 |
Contributor
💔 All backports failed
Manual backportTo create the backport manually run: Questions ?Please refer to the Backport tool documentation |
Contributor
Author
💚 All backports created successfully
Note: Successful backport PRs will be merged automatically after passing CI. Questions ?Please refer to the Backport tool documentation |
jeramysoucy
added a commit
that referenced
this pull request
Aug 28, 2026
…2 requests (#285344) (#287794) # Backport This will backport the following commits from `main` to `9.4`: - [[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests (#285344)](#285344) <!--- Backport version: 12.0.0 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Jeramy Soucy","email":"[email protected]"},"sourceCommit":{"committedDate":"2026-08-28T09:44:29Z","message":"[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests (#285344)\n\n## Summary\n\nFixes the root cause of https://github.com/elastic/kibana/issues/258232\n— PKI sessions being invalidated after upgrading to Kibana 9.x, where\n`server.protocol` silently defaults to `http2` when `ssl.enabled: true`.\n\nThis is the companion to #285153, which fixes the symptom in `pki.ts`.\nThis PR fixes the underlying socket resolution in Core HTTP.\n\n## Problem\n\nFor HTTP/2 requests, `Http2ServerRequest.socket` returns a `Proxy` over\nthe **stream**, not the session's `TLSSocket`. The load-bearing traps\nbranch on `stream[kSession]`:\n\n```js\n// Node.js lib/internal/http2/compat.js\ngetPrototypeOf(stream) {\n if (stream.session !== undefined) return ReflectGetPrototypeOf(stream.session[kSocket]);\n return ReflectGetPrototypeOf(stream); // <-- Http2Stream, NOT TLSSocket\n}\n```\n\nNode clears `stream[kSession]` whenever the stream is destroyed —\nRST_STREAM, GOAWAY, client abort, timeout. After that point the proxy\nresolves against the `Http2Stream` itself, so every `instanceof\nTLSSocket` check in `KibanaSocket` fails and the accessors degrade:\n\n| accessor | value after stream destruction |\n|---|---|\n| `authorized` | `undefined` |\n| `authorizationError` | `undefined` |\n| `getPeerCertificate(true)` | `null` |\n| `getProtocol()` | `null` |\n| `remoteAddress` | `undefined` |\n\nThis happens on **live, authorized connections** — the TLS handshake\nsucceeded and the client certificate is valid, but the stream-level\nproxy can no longer report it.\n\nKibana's own frontend aborts in-flight requests constantly via\n`AbortController` (search sessions, unified search, autocomplete). On\nHTTP/1.1 an abort closes a dedicated socket; on HTTP/2 it emits\nRST_STREAM on a **shared** session mid-flight, so one cancelled stream\ndegrades the socket view for concurrent requests.\n\n## Fix\n\nResolve the **session-level** socket (`req.stream.session.socket`) at\n`KibanaSocket` construction time instead of using the stream-level\nproxy. The session socket resolves against `session[kSocket]`, is a real\n`TLSSocket`, and remains stable for the lifetime of the TCP connection.\n\nThis is semantically correct: in HTTP/2 the client certificate is a\nproperty of the **connection**, not of an individual stream.\n\nFalls back to `req.socket` when the session socket is unavailable —\neither an HTTP/1.1 request (no `stream` property) or a fully destroyed\nsession (`session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND`).\n\n## Secondary fix\n\n`audit_service.ts` reads `request.socket.remoteAddress` for audit log\nclient IP enrichment. On destroyed HTTP/2 streams this silently yielded\n`undefined`, losing the client IP in audit records for **all** auth\nproviders. This patch fixes that too, since the session socket carries a\nstable `remoteAddress`.\n\n## Testing\n\nFour new unit tests in `socket.test.ts` covering `resolveRawSocket`:\n- HTTP/1.1 request (no `stream` property) → returns `req.socket`\n- HTTP/2 request → returns the session-level socket\n- `stream.session` undefined → falls back to `req.socket`\n- `session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND` → falls back to\n`req.socket`\n\n```\nnode scripts/jest src/core/packages/http/router-server-internal/src/socket.test.ts\n# 20 passed, 20 total\n```\n\n## Relationship to #285153\n\n- **#285153** (`pki.ts`) — defensive guard so an unknowable socket state\n(`authorized === undefined`) is not treated as an explicit cert\nrejection. Narrow, safe to backport.\n- **This PR** (Core HTTP) — removes the condition that produces the\nunknowable state in the first place.\n\nBoth are worth having. The `pki.ts` guard remains correct\ndefense-in-depth for the fully-destroyed-session case, where even the\nsession socket is unavailable.\n\n## Risk\n\nTouches socket resolution for all Core HTTP requests, not just PKI.\nMitigations:\n- HTTP/1.1 requests are unaffected — no `stream` property means the\noriginal `req.socket` is returned unchanged.\n- The HTTP/2 path only changes *which* socket object is wrapped;\n`KibanaSocket`'s own logic is untouched.\n- Fallback preserves today's behaviour whenever the session socket\ncannot be resolved.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## Release note\n\nFixes an issue where audit log records could omit the client IP address\nfor requests made over HTTP/2.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>","sha":"d433465ac0fe5b721a0324706dc138d602287bff","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:http","Team:Core","release_note:fix","backport:version","reviewer:scout","v9.6.0","v9.4.6","v9.5.3"],"title":"[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests","number":285344,"url":"https://github.com/elastic/kibana/pull/285344","mergeCommit":{"message":"[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests (#285344)\n\n## Summary\n\nFixes the root cause of https://github.com/elastic/kibana/issues/258232\n— PKI sessions being invalidated after upgrading to Kibana 9.x, where\n`server.protocol` silently defaults to `http2` when `ssl.enabled: true`.\n\nThis is the companion to #285153, which fixes the symptom in `pki.ts`.\nThis PR fixes the underlying socket resolution in Core HTTP.\n\n## Problem\n\nFor HTTP/2 requests, `Http2ServerRequest.socket` returns a `Proxy` over\nthe **stream**, not the session's `TLSSocket`. The load-bearing traps\nbranch on `stream[kSession]`:\n\n```js\n// Node.js lib/internal/http2/compat.js\ngetPrototypeOf(stream) {\n if (stream.session !== undefined) return ReflectGetPrototypeOf(stream.session[kSocket]);\n return ReflectGetPrototypeOf(stream); // <-- Http2Stream, NOT TLSSocket\n}\n```\n\nNode clears `stream[kSession]` whenever the stream is destroyed —\nRST_STREAM, GOAWAY, client abort, timeout. After that point the proxy\nresolves against the `Http2Stream` itself, so every `instanceof\nTLSSocket` check in `KibanaSocket` fails and the accessors degrade:\n\n| accessor | value after stream destruction |\n|---|---|\n| `authorized` | `undefined` |\n| `authorizationError` | `undefined` |\n| `getPeerCertificate(true)` | `null` |\n| `getProtocol()` | `null` |\n| `remoteAddress` | `undefined` |\n\nThis happens on **live, authorized connections** — the TLS handshake\nsucceeded and the client certificate is valid, but the stream-level\nproxy can no longer report it.\n\nKibana's own frontend aborts in-flight requests constantly via\n`AbortController` (search sessions, unified search, autocomplete). On\nHTTP/1.1 an abort closes a dedicated socket; on HTTP/2 it emits\nRST_STREAM on a **shared** session mid-flight, so one cancelled stream\ndegrades the socket view for concurrent requests.\n\n## Fix\n\nResolve the **session-level** socket (`req.stream.session.socket`) at\n`KibanaSocket` construction time instead of using the stream-level\nproxy. The session socket resolves against `session[kSocket]`, is a real\n`TLSSocket`, and remains stable for the lifetime of the TCP connection.\n\nThis is semantically correct: in HTTP/2 the client certificate is a\nproperty of the **connection**, not of an individual stream.\n\nFalls back to `req.socket` when the session socket is unavailable —\neither an HTTP/1.1 request (no `stream` property) or a fully destroyed\nsession (`session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND`).\n\n## Secondary fix\n\n`audit_service.ts` reads `request.socket.remoteAddress` for audit log\nclient IP enrichment. On destroyed HTTP/2 streams this silently yielded\n`undefined`, losing the client IP in audit records for **all** auth\nproviders. This patch fixes that too, since the session socket carries a\nstable `remoteAddress`.\n\n## Testing\n\nFour new unit tests in `socket.test.ts` covering `resolveRawSocket`:\n- HTTP/1.1 request (no `stream` property) → returns `req.socket`\n- HTTP/2 request → returns the session-level socket\n- `stream.session` undefined → falls back to `req.socket`\n- `session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND` → falls back to\n`req.socket`\n\n```\nnode scripts/jest src/core/packages/http/router-server-internal/src/socket.test.ts\n# 20 passed, 20 total\n```\n\n## Relationship to #285153\n\n- **#285153** (`pki.ts`) — defensive guard so an unknowable socket state\n(`authorized === undefined`) is not treated as an explicit cert\nrejection. Narrow, safe to backport.\n- **This PR** (Core HTTP) — removes the condition that produces the\nunknowable state in the first place.\n\nBoth are worth having. The `pki.ts` guard remains correct\ndefense-in-depth for the fully-destroyed-session case, where even the\nsession socket is unavailable.\n\n## Risk\n\nTouches socket resolution for all Core HTTP requests, not just PKI.\nMitigations:\n- HTTP/1.1 requests are unaffected — no `stream` property means the\noriginal `req.socket` is returned unchanged.\n- The HTTP/2 path only changes *which* socket object is wrapped;\n`KibanaSocket`'s own logic is untouched.\n- Fallback preserves today's behaviour whenever the session socket\ncannot be resolved.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## Release note\n\nFixes an issue where audit log records could omit the client IP address\nfor requests made over HTTP/2.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>","sha":"d433465ac0fe5b721a0324706dc138d602287bff"}},"sourceBranch":"main","suggestedTargetBranches":["9.4","9.5"],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/285344","number":285344,"mergeCommit":{"message":"[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests (#285344)\n\n## Summary\n\nFixes the root cause of https://github.com/elastic/kibana/issues/258232\n— PKI sessions being invalidated after upgrading to Kibana 9.x, where\n`server.protocol` silently defaults to `http2` when `ssl.enabled: true`.\n\nThis is the companion to #285153, which fixes the symptom in `pki.ts`.\nThis PR fixes the underlying socket resolution in Core HTTP.\n\n## Problem\n\nFor HTTP/2 requests, `Http2ServerRequest.socket` returns a `Proxy` over\nthe **stream**, not the session's `TLSSocket`. The load-bearing traps\nbranch on `stream[kSession]`:\n\n```js\n// Node.js lib/internal/http2/compat.js\ngetPrototypeOf(stream) {\n if (stream.session !== undefined) return ReflectGetPrototypeOf(stream.session[kSocket]);\n return ReflectGetPrototypeOf(stream); // <-- Http2Stream, NOT TLSSocket\n}\n```\n\nNode clears `stream[kSession]` whenever the stream is destroyed —\nRST_STREAM, GOAWAY, client abort, timeout. After that point the proxy\nresolves against the `Http2Stream` itself, so every `instanceof\nTLSSocket` check in `KibanaSocket` fails and the accessors degrade:\n\n| accessor | value after stream destruction |\n|---|---|\n| `authorized` | `undefined` |\n| `authorizationError` | `undefined` |\n| `getPeerCertificate(true)` | `null` |\n| `getProtocol()` | `null` |\n| `remoteAddress` | `undefined` |\n\nThis happens on **live, authorized connections** — the TLS handshake\nsucceeded and the client certificate is valid, but the stream-level\nproxy can no longer report it.\n\nKibana's own frontend aborts in-flight requests constantly via\n`AbortController` (search sessions, unified search, autocomplete). On\nHTTP/1.1 an abort closes a dedicated socket; on HTTP/2 it emits\nRST_STREAM on a **shared** session mid-flight, so one cancelled stream\ndegrades the socket view for concurrent requests.\n\n## Fix\n\nResolve the **session-level** socket (`req.stream.session.socket`) at\n`KibanaSocket` construction time instead of using the stream-level\nproxy. The session socket resolves against `session[kSocket]`, is a real\n`TLSSocket`, and remains stable for the lifetime of the TCP connection.\n\nThis is semantically correct: in HTTP/2 the client certificate is a\nproperty of the **connection**, not of an individual stream.\n\nFalls back to `req.socket` when the session socket is unavailable —\neither an HTTP/1.1 request (no `stream` property) or a fully destroyed\nsession (`session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND`).\n\n## Secondary fix\n\n`audit_service.ts` reads `request.socket.remoteAddress` for audit log\nclient IP enrichment. On destroyed HTTP/2 streams this silently yielded\n`undefined`, losing the client IP in audit records for **all** auth\nproviders. This patch fixes that too, since the session socket carries a\nstable `remoteAddress`.\n\n## Testing\n\nFour new unit tests in `socket.test.ts` covering `resolveRawSocket`:\n- HTTP/1.1 request (no `stream` property) → returns `req.socket`\n- HTTP/2 request → returns the session-level socket\n- `stream.session` undefined → falls back to `req.socket`\n- `session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND` → falls back to\n`req.socket`\n\n```\nnode scripts/jest src/core/packages/http/router-server-internal/src/socket.test.ts\n# 20 passed, 20 total\n```\n\n## Relationship to #285153\n\n- **#285153** (`pki.ts`) — defensive guard so an unknowable socket state\n(`authorized === undefined`) is not treated as an explicit cert\nrejection. Narrow, safe to backport.\n- **This PR** (Core HTTP) — removes the condition that produces the\nunknowable state in the first place.\n\nBoth are worth having. The `pki.ts` guard remains correct\ndefense-in-depth for the fully-destroyed-session case, where even the\nsession socket is unavailable.\n\n## Risk\n\nTouches socket resolution for all Core HTTP requests, not just PKI.\nMitigations:\n- HTTP/1.1 requests are unaffected — no `stream` property means the\noriginal `req.socket` is returned unchanged.\n- The HTTP/2 path only changes *which* socket object is wrapped;\n`KibanaSocket`'s own logic is untouched.\n- Fallback preserves today's behaviour whenever the session socket\ncannot be resolved.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## Release note\n\nFixes an issue where audit log records could omit the client IP address\nfor requests made over HTTP/2.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>","sha":"d433465ac0fe5b721a0324706dc138d602287bff"}},{"branch":"9.4","label":"v9.4.6","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"9.5","label":"v9.5.3","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT-->
jeramysoucy
added a commit
that referenced
this pull request
Aug 28, 2026
…2 requests (#285344) (#287792) # Backport This will backport the following commits from `main` to `9.5`: - [[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests (#285344)](#285344) <!--- Backport version: 12.0.0 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Jeramy Soucy","email":"[email protected]"},"sourceCommit":{"committedDate":"2026-08-28T09:44:29Z","message":"[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests (#285344)\n\n## Summary\n\nFixes the root cause of https://github.com/elastic/kibana/issues/258232\n— PKI sessions being invalidated after upgrading to Kibana 9.x, where\n`server.protocol` silently defaults to `http2` when `ssl.enabled: true`.\n\nThis is the companion to #285153, which fixes the symptom in `pki.ts`.\nThis PR fixes the underlying socket resolution in Core HTTP.\n\n## Problem\n\nFor HTTP/2 requests, `Http2ServerRequest.socket` returns a `Proxy` over\nthe **stream**, not the session's `TLSSocket`. The load-bearing traps\nbranch on `stream[kSession]`:\n\n```js\n// Node.js lib/internal/http2/compat.js\ngetPrototypeOf(stream) {\n if (stream.session !== undefined) return ReflectGetPrototypeOf(stream.session[kSocket]);\n return ReflectGetPrototypeOf(stream); // <-- Http2Stream, NOT TLSSocket\n}\n```\n\nNode clears `stream[kSession]` whenever the stream is destroyed —\nRST_STREAM, GOAWAY, client abort, timeout. After that point the proxy\nresolves against the `Http2Stream` itself, so every `instanceof\nTLSSocket` check in `KibanaSocket` fails and the accessors degrade:\n\n| accessor | value after stream destruction |\n|---|---|\n| `authorized` | `undefined` |\n| `authorizationError` | `undefined` |\n| `getPeerCertificate(true)` | `null` |\n| `getProtocol()` | `null` |\n| `remoteAddress` | `undefined` |\n\nThis happens on **live, authorized connections** — the TLS handshake\nsucceeded and the client certificate is valid, but the stream-level\nproxy can no longer report it.\n\nKibana's own frontend aborts in-flight requests constantly via\n`AbortController` (search sessions, unified search, autocomplete). On\nHTTP/1.1 an abort closes a dedicated socket; on HTTP/2 it emits\nRST_STREAM on a **shared** session mid-flight, so one cancelled stream\ndegrades the socket view for concurrent requests.\n\n## Fix\n\nResolve the **session-level** socket (`req.stream.session.socket`) at\n`KibanaSocket` construction time instead of using the stream-level\nproxy. The session socket resolves against `session[kSocket]`, is a real\n`TLSSocket`, and remains stable for the lifetime of the TCP connection.\n\nThis is semantically correct: in HTTP/2 the client certificate is a\nproperty of the **connection**, not of an individual stream.\n\nFalls back to `req.socket` when the session socket is unavailable —\neither an HTTP/1.1 request (no `stream` property) or a fully destroyed\nsession (`session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND`).\n\n## Secondary fix\n\n`audit_service.ts` reads `request.socket.remoteAddress` for audit log\nclient IP enrichment. On destroyed HTTP/2 streams this silently yielded\n`undefined`, losing the client IP in audit records for **all** auth\nproviders. This patch fixes that too, since the session socket carries a\nstable `remoteAddress`.\n\n## Testing\n\nFour new unit tests in `socket.test.ts` covering `resolveRawSocket`:\n- HTTP/1.1 request (no `stream` property) → returns `req.socket`\n- HTTP/2 request → returns the session-level socket\n- `stream.session` undefined → falls back to `req.socket`\n- `session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND` → falls back to\n`req.socket`\n\n```\nnode scripts/jest src/core/packages/http/router-server-internal/src/socket.test.ts\n# 20 passed, 20 total\n```\n\n## Relationship to #285153\n\n- **#285153** (`pki.ts`) — defensive guard so an unknowable socket state\n(`authorized === undefined`) is not treated as an explicit cert\nrejection. Narrow, safe to backport.\n- **This PR** (Core HTTP) — removes the condition that produces the\nunknowable state in the first place.\n\nBoth are worth having. The `pki.ts` guard remains correct\ndefense-in-depth for the fully-destroyed-session case, where even the\nsession socket is unavailable.\n\n## Risk\n\nTouches socket resolution for all Core HTTP requests, not just PKI.\nMitigations:\n- HTTP/1.1 requests are unaffected — no `stream` property means the\noriginal `req.socket` is returned unchanged.\n- The HTTP/2 path only changes *which* socket object is wrapped;\n`KibanaSocket`'s own logic is untouched.\n- Fallback preserves today's behaviour whenever the session socket\ncannot be resolved.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## Release note\n\nFixes an issue where audit log records could omit the client IP address\nfor requests made over HTTP/2.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>","sha":"d433465ac0fe5b721a0324706dc138d602287bff","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:http","Team:Core","release_note:fix","backport:version","reviewer:scout","v9.6.0","v9.4.6","v9.5.3"],"title":"[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests","number":285344,"url":"https://github.com/elastic/kibana/pull/285344","mergeCommit":{"message":"[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests (#285344)\n\n## Summary\n\nFixes the root cause of https://github.com/elastic/kibana/issues/258232\n— PKI sessions being invalidated after upgrading to Kibana 9.x, where\n`server.protocol` silently defaults to `http2` when `ssl.enabled: true`.\n\nThis is the companion to #285153, which fixes the symptom in `pki.ts`.\nThis PR fixes the underlying socket resolution in Core HTTP.\n\n## Problem\n\nFor HTTP/2 requests, `Http2ServerRequest.socket` returns a `Proxy` over\nthe **stream**, not the session's `TLSSocket`. The load-bearing traps\nbranch on `stream[kSession]`:\n\n```js\n// Node.js lib/internal/http2/compat.js\ngetPrototypeOf(stream) {\n if (stream.session !== undefined) return ReflectGetPrototypeOf(stream.session[kSocket]);\n return ReflectGetPrototypeOf(stream); // <-- Http2Stream, NOT TLSSocket\n}\n```\n\nNode clears `stream[kSession]` whenever the stream is destroyed —\nRST_STREAM, GOAWAY, client abort, timeout. After that point the proxy\nresolves against the `Http2Stream` itself, so every `instanceof\nTLSSocket` check in `KibanaSocket` fails and the accessors degrade:\n\n| accessor | value after stream destruction |\n|---|---|\n| `authorized` | `undefined` |\n| `authorizationError` | `undefined` |\n| `getPeerCertificate(true)` | `null` |\n| `getProtocol()` | `null` |\n| `remoteAddress` | `undefined` |\n\nThis happens on **live, authorized connections** — the TLS handshake\nsucceeded and the client certificate is valid, but the stream-level\nproxy can no longer report it.\n\nKibana's own frontend aborts in-flight requests constantly via\n`AbortController` (search sessions, unified search, autocomplete). On\nHTTP/1.1 an abort closes a dedicated socket; on HTTP/2 it emits\nRST_STREAM on a **shared** session mid-flight, so one cancelled stream\ndegrades the socket view for concurrent requests.\n\n## Fix\n\nResolve the **session-level** socket (`req.stream.session.socket`) at\n`KibanaSocket` construction time instead of using the stream-level\nproxy. The session socket resolves against `session[kSocket]`, is a real\n`TLSSocket`, and remains stable for the lifetime of the TCP connection.\n\nThis is semantically correct: in HTTP/2 the client certificate is a\nproperty of the **connection**, not of an individual stream.\n\nFalls back to `req.socket` when the session socket is unavailable —\neither an HTTP/1.1 request (no `stream` property) or a fully destroyed\nsession (`session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND`).\n\n## Secondary fix\n\n`audit_service.ts` reads `request.socket.remoteAddress` for audit log\nclient IP enrichment. On destroyed HTTP/2 streams this silently yielded\n`undefined`, losing the client IP in audit records for **all** auth\nproviders. This patch fixes that too, since the session socket carries a\nstable `remoteAddress`.\n\n## Testing\n\nFour new unit tests in `socket.test.ts` covering `resolveRawSocket`:\n- HTTP/1.1 request (no `stream` property) → returns `req.socket`\n- HTTP/2 request → returns the session-level socket\n- `stream.session` undefined → falls back to `req.socket`\n- `session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND` → falls back to\n`req.socket`\n\n```\nnode scripts/jest src/core/packages/http/router-server-internal/src/socket.test.ts\n# 20 passed, 20 total\n```\n\n## Relationship to #285153\n\n- **#285153** (`pki.ts`) — defensive guard so an unknowable socket state\n(`authorized === undefined`) is not treated as an explicit cert\nrejection. Narrow, safe to backport.\n- **This PR** (Core HTTP) — removes the condition that produces the\nunknowable state in the first place.\n\nBoth are worth having. The `pki.ts` guard remains correct\ndefense-in-depth for the fully-destroyed-session case, where even the\nsession socket is unavailable.\n\n## Risk\n\nTouches socket resolution for all Core HTTP requests, not just PKI.\nMitigations:\n- HTTP/1.1 requests are unaffected — no `stream` property means the\noriginal `req.socket` is returned unchanged.\n- The HTTP/2 path only changes *which* socket object is wrapped;\n`KibanaSocket`'s own logic is untouched.\n- Fallback preserves today's behaviour whenever the session socket\ncannot be resolved.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## Release note\n\nFixes an issue where audit log records could omit the client IP address\nfor requests made over HTTP/2.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>","sha":"d433465ac0fe5b721a0324706dc138d602287bff"}},"sourceBranch":"main","suggestedTargetBranches":["9.4","9.5"],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/285344","number":285344,"mergeCommit":{"message":"[HTTP/2] Fix KibanaSocket to use session-level socket for HTTP/2 requests (#285344)\n\n## Summary\n\nFixes the root cause of https://github.com/elastic/kibana/issues/258232\n— PKI sessions being invalidated after upgrading to Kibana 9.x, where\n`server.protocol` silently defaults to `http2` when `ssl.enabled: true`.\n\nThis is the companion to #285153, which fixes the symptom in `pki.ts`.\nThis PR fixes the underlying socket resolution in Core HTTP.\n\n## Problem\n\nFor HTTP/2 requests, `Http2ServerRequest.socket` returns a `Proxy` over\nthe **stream**, not the session's `TLSSocket`. The load-bearing traps\nbranch on `stream[kSession]`:\n\n```js\n// Node.js lib/internal/http2/compat.js\ngetPrototypeOf(stream) {\n if (stream.session !== undefined) return ReflectGetPrototypeOf(stream.session[kSocket]);\n return ReflectGetPrototypeOf(stream); // <-- Http2Stream, NOT TLSSocket\n}\n```\n\nNode clears `stream[kSession]` whenever the stream is destroyed —\nRST_STREAM, GOAWAY, client abort, timeout. After that point the proxy\nresolves against the `Http2Stream` itself, so every `instanceof\nTLSSocket` check in `KibanaSocket` fails and the accessors degrade:\n\n| accessor | value after stream destruction |\n|---|---|\n| `authorized` | `undefined` |\n| `authorizationError` | `undefined` |\n| `getPeerCertificate(true)` | `null` |\n| `getProtocol()` | `null` |\n| `remoteAddress` | `undefined` |\n\nThis happens on **live, authorized connections** — the TLS handshake\nsucceeded and the client certificate is valid, but the stream-level\nproxy can no longer report it.\n\nKibana's own frontend aborts in-flight requests constantly via\n`AbortController` (search sessions, unified search, autocomplete). On\nHTTP/1.1 an abort closes a dedicated socket; on HTTP/2 it emits\nRST_STREAM on a **shared** session mid-flight, so one cancelled stream\ndegrades the socket view for concurrent requests.\n\n## Fix\n\nResolve the **session-level** socket (`req.stream.session.socket`) at\n`KibanaSocket` construction time instead of using the stream-level\nproxy. The session socket resolves against `session[kSocket]`, is a real\n`TLSSocket`, and remains stable for the lifetime of the TCP connection.\n\nThis is semantically correct: in HTTP/2 the client certificate is a\nproperty of the **connection**, not of an individual stream.\n\nFalls back to `req.socket` when the session socket is unavailable —\neither an HTTP/1.1 request (no `stream` property) or a fully destroyed\nsession (`session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND`).\n\n## Secondary fix\n\n`audit_service.ts` reads `request.socket.remoteAddress` for audit log\nclient IP enrichment. On destroyed HTTP/2 streams this silently yielded\n`undefined`, losing the client IP in audit records for **all** auth\nproviders. This patch fixes that too, since the session socket carries a\nstable `remoteAddress`.\n\n## Testing\n\nFour new unit tests in `socket.test.ts` covering `resolveRawSocket`:\n- HTTP/1.1 request (no `stream` property) → returns `req.socket`\n- HTTP/2 request → returns the session-level socket\n- `stream.session` undefined → falls back to `req.socket`\n- `session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND` → falls back to\n`req.socket`\n\n```\nnode scripts/jest src/core/packages/http/router-server-internal/src/socket.test.ts\n# 20 passed, 20 total\n```\n\n## Relationship to #285153\n\n- **#285153** (`pki.ts`) — defensive guard so an unknowable socket state\n(`authorized === undefined`) is not treated as an explicit cert\nrejection. Narrow, safe to backport.\n- **This PR** (Core HTTP) — removes the condition that produces the\nunknowable state in the first place.\n\nBoth are worth having. The `pki.ts` guard remains correct\ndefense-in-depth for the fully-destroyed-session case, where even the\nsession socket is unavailable.\n\n## Risk\n\nTouches socket resolution for all Core HTTP requests, not just PKI.\nMitigations:\n- HTTP/1.1 requests are unaffected — no `stream` property means the\noriginal `req.socket` is returned unchanged.\n- The HTTP/2 path only changes *which* socket object is wrapped;\n`KibanaSocket`'s own logic is untouched.\n- Fallback preserves today's behaviour whenever the session socket\ncannot be resolved.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## Release note\n\nFixes an issue where audit log records could omit the client IP address\nfor requests made over HTTP/2.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>","sha":"d433465ac0fe5b721a0324706dc138d602287bff"}},{"branch":"9.4","label":"v9.4.6","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"9.5","label":"v9.5.3","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT-->
dej611
pushed a commit
to dej611/kibana
that referenced
this pull request
Aug 31, 2026
…ests (elastic#285344) ## Summary Fixes the root cause of elastic#258232 — PKI sessions being invalidated after upgrading to Kibana 9.x, where `server.protocol` silently defaults to `http2` when `ssl.enabled: true`. This is the companion to elastic#285153, which fixes the symptom in `pki.ts`. This PR fixes the underlying socket resolution in Core HTTP. ## Problem For HTTP/2 requests, `Http2ServerRequest.socket` returns a `Proxy` over the **stream**, not the session's `TLSSocket`. The load-bearing traps branch on `stream[kSession]`: ```js // Node.js lib/internal/http2/compat.js getPrototypeOf(stream) { if (stream.session !== undefined) return ReflectGetPrototypeOf(stream.session[kSocket]); return ReflectGetPrototypeOf(stream); // <-- Http2Stream, NOT TLSSocket } ``` Node clears `stream[kSession]` whenever the stream is destroyed — RST_STREAM, GOAWAY, client abort, timeout. After that point the proxy resolves against the `Http2Stream` itself, so every `instanceof TLSSocket` check in `KibanaSocket` fails and the accessors degrade: | accessor | value after stream destruction | |---|---| | `authorized` | `undefined` | | `authorizationError` | `undefined` | | `getPeerCertificate(true)` | `null` | | `getProtocol()` | `null` | | `remoteAddress` | `undefined` | This happens on **live, authorized connections** — the TLS handshake succeeded and the client certificate is valid, but the stream-level proxy can no longer report it. Kibana's own frontend aborts in-flight requests constantly via `AbortController` (search sessions, unified search, autocomplete). On HTTP/1.1 an abort closes a dedicated socket; on HTTP/2 it emits RST_STREAM on a **shared** session mid-flight, so one cancelled stream degrades the socket view for concurrent requests. ## Fix Resolve the **session-level** socket (`req.stream.session.socket`) at `KibanaSocket` construction time instead of using the stream-level proxy. The session socket resolves against `session[kSocket]`, is a real `TLSSocket`, and remains stable for the lifetime of the TCP connection. This is semantically correct: in HTTP/2 the client certificate is a property of the **connection**, not of an individual stream. Falls back to `req.socket` when the session socket is unavailable — either an HTTP/1.1 request (no `stream` property) or a fully destroyed session (`session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND`). ## Secondary fix `audit_service.ts` reads `request.socket.remoteAddress` for audit log client IP enrichment. On destroyed HTTP/2 streams this silently yielded `undefined`, losing the client IP in audit records for **all** auth providers. This patch fixes that too, since the session socket carries a stable `remoteAddress`. ## Testing Four new unit tests in `socket.test.ts` covering `resolveRawSocket`: - HTTP/1.1 request (no `stream` property) → returns `req.socket` - HTTP/2 request → returns the session-level socket - `stream.session` undefined → falls back to `req.socket` - `session.socket` throws `ERR_HTTP2_SOCKET_UNBOUND` → falls back to `req.socket` ``` node scripts/jest src/core/packages/http/router-server-internal/src/socket.test.ts # 20 passed, 20 total ``` ## Relationship to elastic#285153 - **elastic#285153** (`pki.ts`) — defensive guard so an unknowable socket state (`authorized === undefined`) is not treated as an explicit cert rejection. Narrow, safe to backport. - **This PR** (Core HTTP) — removes the condition that produces the unknowable state in the first place. Both are worth having. The `pki.ts` guard remains correct defense-in-depth for the fully-destroyed-session case, where even the session socket is unavailable. ## Risk Touches socket resolution for all Core HTTP requests, not just PKI. Mitigations: - HTTP/1.1 requests are unaffected — no `stream` property means the original `req.socket` is returned unchanged. - The HTTP/2 path only changes *which* socket object is wrapped; `KibanaSocket`'s own logic is untouched. - Fallback preserves today's behaviour whenever the session socket cannot be resolved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Release note Fixes an issue where audit log records could omit the client IP address for requests made over HTTP/2. --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: kibanamachine <[email protected]>
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.
Summary
Fixes the root cause of #258232 — PKI sessions being invalidated after upgrading to Kibana 9.x, where
server.protocolsilently defaults tohttp2whenssl.enabled: true.This is the companion to #285153, which fixes the symptom in
pki.ts. This PR fixes the underlying socket resolution in Core HTTP.Problem
For HTTP/2 requests,
Http2ServerRequest.socketreturns aProxyover the stream, not the session'sTLSSocket. The load-bearing traps branch onstream[kSession]:Node clears
stream[kSession]whenever the stream is destroyed — RST_STREAM, GOAWAY, client abort, timeout. After that point the proxy resolves against theHttp2Streamitself, so everyinstanceof TLSSocketcheck inKibanaSocketfails and the accessors degrade:authorizedundefinedauthorizationErrorundefinedgetPeerCertificate(true)nullgetProtocol()nullremoteAddressundefinedThis happens on live, authorized connections — the TLS handshake succeeded and the client certificate is valid, but the stream-level proxy can no longer report it.
Kibana's own frontend aborts in-flight requests constantly via
AbortController(search sessions, unified search, autocomplete). On HTTP/1.1 an abort closes a dedicated socket; on HTTP/2 it emits RST_STREAM on a shared session mid-flight, so one cancelled stream degrades the socket view for concurrent requests.Fix
Resolve the session-level socket (
req.stream.session.socket) atKibanaSocketconstruction time instead of using the stream-level proxy. The session socket resolves againstsession[kSocket], is a realTLSSocket, and remains stable for the lifetime of the TCP connection.This is semantically correct: in HTTP/2 the client certificate is a property of the connection, not of an individual stream.
Falls back to
req.socketwhen the session socket is unavailable — either an HTTP/1.1 request (nostreamproperty) or a fully destroyed session (session.socketthrowsERR_HTTP2_SOCKET_UNBOUND).Secondary fix
audit_service.tsreadsrequest.socket.remoteAddressfor audit log client IP enrichment. On destroyed HTTP/2 streams this silently yieldedundefined, losing the client IP in audit records for all auth providers. This patch fixes that too, since the session socket carries a stableremoteAddress.Testing
Four new unit tests in
socket.test.tscoveringresolveRawSocket:streamproperty) → returnsreq.socketstream.sessionundefined → falls back toreq.socketsession.socketthrowsERR_HTTP2_SOCKET_UNBOUND→ falls back toreq.socketRelationship to #285153
pki.ts) — defensive guard so an unknowable socket state (authorized === undefined) is not treated as an explicit cert rejection. Narrow, safe to backport.Both are worth having. The
pki.tsguard remains correct defense-in-depth for the fully-destroyed-session case, where even the session socket is unavailable.Risk
Touches socket resolution for all Core HTTP requests, not just PKI. Mitigations:
streamproperty means the originalreq.socketis returned unchanged.KibanaSocket's own logic is untouched.🤖 Generated with Claude Code
Release note
Fixes an issue where audit log records could omit the client IP address for requests made over HTTP/2.