[Security] Fix PKI session invalidation on HTTP/2 stream destruction - #285153
Conversation
When an HTTP/2 stream is destroyed mid-request (e.g. from an AbortController cancel / browser navigation abort, which emits RST_STREAM), Node's Http2ServerRequest.socket returns a Proxy whose getPrototypeOf trap falls back to the Http2Stream prototype instead of TLSSocket. This causes every instanceof TLSSocket check in KibanaSocket to return false, making socket.authorized === undefined and getPeerCertificate() === null. The guard in authenticateViaState was written to handle "connection already closed" (peerCertificate === null && authorized), but the truthy check passed on HTTP/1.1 (where a closed TLSSocket keeps authorized === true) and silently failed on HTTP/2 (where authorized === undefined is falsy). The code then entered the token-invalidation block, revoked the ES access token, and returned notHandled() — which authenticate() converted to Boom.unauthorized(), causing updateSessionValue() to delete the server-side session and log out the user. The fix distinguishes "definitively unauthorized" (false) from "unknown socket state" (undefined) by changing the guard from peerCertificate === null && authorized to peerCertificate === null && authorized !== false Only authorized === false (cert presented but rejected) should trigger token invalidation. undefined (socket state unknowable) should fall through to the same non-401 soft-fail path as authorized === true with no cert, preserving the session. Also improves the diagnostic log in authenticateViaPeerCertificate to distinguish the two cases, making production logs actionable. Adds two regression tests: - notHandled() + correct log when socket.authorized is undefined (HTTP/2 degraded socket, no session) - token NOT invalidated and session preserved when socket.authorized is undefined and session state exists (the primary regression lock) Both tests fail on main and pass after this fix. Fixes: elastic#258232 Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
|
Pinging @elastic/kibana-security (Team:Security) |
Document that when an HTTP/2 stream is destroyed, KibanaSocket returns authorized === undefined (not false) and getPeerCertificate === null. A plain net.Socket is used as a test double because it produces the same instanceof TLSSocket === false result as the destroyed-stream proxy. The key invariant: undefined means "socket state is unknown" (stream destroyed before it could be read), not "cert was rejected" (false). Code that reads authorized must treat these two values differently. See kibana#258232 and the fix in pki.ts. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Adds a functional test that reproduces the session-invalidation bug using real RST_STREAM frames — something supertest (HTTP/1.1) cannot do. The test: 1. Establishes a PKI session via supertest / HTTP/1.1 2. Opens a raw http2.connect() session with the PKI client certificate 3. Fires 20 concurrent slow requests (/authentication/slow/me, 10s hold) to ensure Kibana's auth lifecycle is in flight when RST_STREAM arrives 4. Cancels all of them with NGHTTP2_CANCEL after 150ms 5. Sends a normal verification request on the same HTTP/2 session 6. Asserts the session is still valid (200, username=first_client) Before the pki.ts fix: at least one RST_STREAM would cause authorized === undefined to reach the token-invalidation block, revoke the ES access token, and return 401 on the verification request. After the fix: the guard correctly treats undefined as indeterminate and the session survives all RST_STREAM frames. Also adds pki.http2.stress.config.ts (extends pki.http2.config.ts, adds debug security logging) and registers it in ftr_platform_stateful_configs.yml. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
Reviewed the PKI HTTP/2 session-invalidation fix. The core change in pki.ts (authorized truthy check → !== false) is correct: KibanaSocket.authorized returns undefined for a destroyed HTTP/2 stream (non-TLSSocket) vs false for a rejected cert, so treating undefined like true correctly preserves the session in the indeterminate case without ever granting access. Unit tests deterministically cover both the token-invalidation and notHandled paths. One non-blocking comment on the shared kbn-scout config coupling.
Generated by Claude Reviewer for #285153 · 142.1 AIC · ⌖ 15.8 AIC · ⊞ 4.6K
| addOrReplaceArg(kbnServerArgs, 'elasticsearch.hosts', 'https://localhost:9220'); | ||
| addOrReplaceArg(kbnServerArgs, 'elasticsearch.ssl.certificateAuthorities', CA_CERT_PATH); | ||
| addOrReplaceArg(kbnServerArgs, 'server.ssl.clientAuthentication', 'optional'); | ||
| kbnServerArgs.push( |
There was a problem hiding this comment.
This wires a solution-specific x-pack test plugin (x-pack/platform/test/security_functional/plugins/test_endpoints) into the shared kbn-scout PKI base config (pkiConfig is an exported platform package config). Every consumer of this config — including PKI Scout suites owned by other teams — will now load this security functional-test plugin, and via its init_routes.ts it monkey-patches PKIAuthenticationProvider.prototype.authenticate globally on startup (header-gated, but the wrapper is installed for all requests).
That couples a shared platform package to an x-pack solution test fixture and changes the runtime environment for unrelated PKI test runs. Consider injecting the --plugin-path from the security plugin's own Scout config layer (e.g. extend pkiConfig in the security test config) rather than mutating the shared base config, so the coupling and the prototype patch stay scoped to the suite that needs them.
| REPO_ROOT, | ||
| 'x-pack/platform/test/security_functional/plugins/test_endpoints' | ||
| )}` | ||
| ); |
There was a problem hiding this comment.
Custom server config earns its keep
This modifies the shared pki config set — used by the existing PKI login/no-role-mapping UI specs — to unconditionally load the test_endpoints functional-test plugin. Only the new pki_http2_stream_cancel.spec.ts needs it, so consider isolating the plugin path to that spec instead of pulling it into every consumer of the shared config.
See details
The shared pki config set is imported by pki/stateful/classic.stateful.config.ts and served to any spec under test/scout_pki/**. Today that includes pki_login.spec.ts and pki_no_role_mapping.spec.ts, neither of which touches /authentication/preauth_holds — they now boot Kibana with a test-only plugin they dont exercise. It also couples the @kbn/scout package to a plugin owned by x-pack/platform/test/security_functional/plugins/test_endpoints, so a future move/rename there breaks all scout pki consumers.
Because --plugin-path is a startup-only argument, apiServices.core.settings() isnt a substitute here — the alternative is scoping the plugin path to just the new HTTP/2 API spec. A few options worth considering:
- Define a narrower config variant (e.g. a
pki_http2_stressset that composespkiConfigand appends the plugin path) and point the new API playwright config set at it, leaving the sharedpkiset untouched. - Or keep the shared set as-is and register the extra plugin path via the API tests own playwright/scout config.
Either keeps blast radius on the one spec that actually needs it.
Share feedback in the #appex-qa Slack channel.
| import { apiTest as baseApiTest } from '@kbn/scout'; | ||
|
|
||
| import { PkiHttp2Client } from './pki_http2_client'; | ||
| import { FIRST_CLIENT_P12 } from '../../ui/fixtures/constants'; |
There was a problem hiding this comment.
API fixture reaches into the sibling UI fixture tree
api/fixtures/index.ts imports FIRST_CLIENT_P12 from ../../ui/fixtures/constants, so this API suite now silently depends on the UI suites file layout — moving or renaming anything under ui/fixtures/ breaks the API tests. Consider hoisting the shared PKI cert constants into a location both scopes can depend on.
See details
The UI constants.ts file exports test artefacts (FIRST_CLIENT_P12, SECOND_CLIENT_P12, KIBANA_TLS_ORIGIN) that are conceptually shared between UI and API suites — none of them are UI-specific. Reaching across ui/ from api/ mixes the two scopes and makes it easy to accidentally couple UI test data to an API implementation detail (or vice versa).
Suggested fixes (any of these is fine):
- Move the PKI cert + host constants to a shared location, e.g.
test/scout_pki/common/constants.ts, and import from bothui/fixtures/*andapi/fixtures/*. - Or duplicate the single constant that this file needs (
FIRST_CLIENT_P12) intoapi/fixtures/constants.ts— it is a smallreadFileSyncand would keep the two suites independent.
Share feedback in the #appex-qa Slack channel.
The Scout test added in 930a826 supersedes this suite and is a stronger regression lock. The FTR test fired 20 concurrent streams and cancelled after a fixed 150ms, hoping to land inside the auth window, and never verified the socket actually degraded — so on a fast agent it could miss the window and still pass against an unpatched build. The Scout test parks inside PKIAuthenticationProvider.authenticate and asserts the state transition explicitly (healthy at park, degraded after RST, session intact after release), so it cannot pass without exercising the bug. It also asserts the authentication provider on the follow-up request, which the FTR test did not. Removes the test, its config, and the FTR manifest entry. pki.config.ts and pki.http2.config.ts are pre-existing and unaffected. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review of the Scout API test (930a826)I traced the full config chain and read all nine files. The Scout test is sound, and it's a stronger regression lock than the FTR test I added. I've removed the FTR stress test accordingly — details at the bottom. It closes a false-green hole in my FTR testMy FTR test fired 20 concurrent streams and cancelled after a fixed 150ms, hoping to land inside the auth window. It never verified the socket actually degraded — on a fast agent it could miss the window entirely and still pass against an unpatched build. That's a poor property for a regression test. The Scout test is deterministic. Parking inside
If the degradation never happens, the poll times out and the test fails. Two traps caught that would have produced silently useless tests
Coverage comparison
The 20-stream concurrency in mine was a trigger amplifier for a probabilistic window, not an independent assertion — both tests assert a subsequent request on the same HTTP/2 session. Nothing is lost by the swap. I also confirmed the CI wiring end to end: Three non-blocking observations
FTR removalRemoved in 24d55c0:
|
…285244) ## Human summary Improves the detection of completed requests by additionally checking for `res.writableEnded`. This works around a quirk in Node.js when running in http/2, which is resolved in 26+: nodejs/node#63249. Checking for `res.writableEnded` does not change anything when running in http/1, as this is always true when `res.writableFinished`. This changes behavior for http/2 by properly detecting aborted requests. This should result in downstream consumers having a more reliable signal for when a client request is aborted, allowing them to cancel jobs such as in-flight ES requests regardless of http protocol. We detected this while writing scout tests for #285153 ## AI description When an HTTP/2 client destroys a stream mid-request (RST_STREAM / NGHTTP2_CANCEL, e.g. an AbortController cancel or browser navigation), Node's Http2ServerResponse emits 'close' with writableFinished === true even though nothing was written. isCompleted() relied on writableFinished alone, so the !isCompleted filter swallowed the event and request.events.aborted$ never fired — consumers (route handlers, auth providers) could not observe HTTP/2 client aborts. HTTP/1 was unaffected because writableFinished stays false there. Treat a request as completed only when writableFinished && writableEnded: writableEnded only becomes true once the server actually ended the response, and is truthful on both protocols and for abrupt TCP-level disconnects. Adds integration coverage in http2_protocol.test.ts driving a real HTTP/2 TLS session that resets the stream while the handler is pending, plus a control test asserting completed$ (and not aborted$) on normal completion. Co-authored-by: Claude Fable 5 <[email protected]>
…uests (#285244) (#285382) # Backport This will backport the following commits from `main` to `9.4`: - [[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests (#285244)](#285244) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Larry Gregory","email":"[email protected]"},"sourceCommit":{"committedDate":"2026-08-17T11:55:38Z","message":"[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests (#285244)\n\n## Human summary\n\nImproves the detection of completed requests by additionally checking\nfor `res.writableEnded`. This works around a quirk in Node.js when\nrunning in http/2, which is resolved in 26+:\nhttps://github.com/nodejs/node/pull/63249.\n\nChecking for `res.writableEnded` does not change anything when running\nin http/1, as this is always true when `res.writableFinished`. This\nchanges behavior for http/2 by properly detecting aborted requests.\n\nThis should result in downstream consumers having a more reliable signal\nfor when a client request is aborted, allowing them to cancel jobs such\nas in-flight ES requests regardless of http protocol.\n\nWe detected this while writing scout tests for\nhttps://github.com//pull/285153\n\n## AI description\nWhen an HTTP/2 client destroys a stream mid-request (RST_STREAM /\nNGHTTP2_CANCEL, e.g. an AbortController cancel or browser navigation),\nNode's Http2ServerResponse emits 'close' with writableFinished === true\neven though nothing was written. isCompleted() relied on\nwritableFinished alone, so the !isCompleted filter swallowed the event\nand request.events.aborted$ never fired — consumers (route handlers,\nauth providers) could not observe HTTP/2 client aborts. HTTP/1 was\nunaffected because writableFinished stays false there.\n\nTreat a request as completed only when writableFinished &&\nwritableEnded: writableEnded only becomes true once the server actually\nended the response, and is truthful on both protocols and for abrupt\nTCP-level disconnects.\n\nAdds integration coverage in http2_protocol.test.ts driving a real\nHTTP/2 TLS session that resets the stream while the handler is pending,\nplus a control test asserting completed$ (and not aborted$) on normal\ncompletion.\n\nCo-authored-by: Claude Fable 5 <[email protected]>","sha":"a19e27a5dc885a40a31998f728c450d8aee27289","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Security","release_note:skip","backport:all-open","v9.6.0"],"title":"[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests","number":285244,"url":"https://github.com/elastic/kibana/pull/285244","mergeCommit":{"message":"[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests (#285244)\n\n## Human summary\n\nImproves the detection of completed requests by additionally checking\nfor `res.writableEnded`. This works around a quirk in Node.js when\nrunning in http/2, which is resolved in 26+:\nhttps://github.com/nodejs/node/pull/63249.\n\nChecking for `res.writableEnded` does not change anything when running\nin http/1, as this is always true when `res.writableFinished`. This\nchanges behavior for http/2 by properly detecting aborted requests.\n\nThis should result in downstream consumers having a more reliable signal\nfor when a client request is aborted, allowing them to cancel jobs such\nas in-flight ES requests regardless of http protocol.\n\nWe detected this while writing scout tests for\nhttps://github.com//pull/285153\n\n## AI description\nWhen an HTTP/2 client destroys a stream mid-request (RST_STREAM /\nNGHTTP2_CANCEL, e.g. an AbortController cancel or browser navigation),\nNode's Http2ServerResponse emits 'close' with writableFinished === true\neven though nothing was written. isCompleted() relied on\nwritableFinished alone, so the !isCompleted filter swallowed the event\nand request.events.aborted$ never fired — consumers (route handlers,\nauth providers) could not observe HTTP/2 client aborts. HTTP/1 was\nunaffected because writableFinished stays false there.\n\nTreat a request as completed only when writableFinished &&\nwritableEnded: writableEnded only becomes true once the server actually\nended the response, and is truthful on both protocols and for abrupt\nTCP-level disconnects.\n\nAdds integration coverage in http2_protocol.test.ts driving a real\nHTTP/2 TLS session that resets the stream while the handler is pending,\nplus a control test asserting completed$ (and not aborted$) on normal\ncompletion.\n\nCo-authored-by: Claude Fable 5 <[email protected]>","sha":"a19e27a5dc885a40a31998f728c450d8aee27289"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/285244","number":285244,"mergeCommit":{"message":"[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests (#285244)\n\n## Human summary\n\nImproves the detection of completed requests by additionally checking\nfor `res.writableEnded`. This works around a quirk in Node.js when\nrunning in http/2, which is resolved in 26+:\nhttps://github.com/nodejs/node/pull/63249.\n\nChecking for `res.writableEnded` does not change anything when running\nin http/1, as this is always true when `res.writableFinished`. This\nchanges behavior for http/2 by properly detecting aborted requests.\n\nThis should result in downstream consumers having a more reliable signal\nfor when a client request is aborted, allowing them to cancel jobs such\nas in-flight ES requests regardless of http protocol.\n\nWe detected this while writing scout tests for\nhttps://github.com//pull/285153\n\n## AI description\nWhen an HTTP/2 client destroys a stream mid-request (RST_STREAM /\nNGHTTP2_CANCEL, e.g. an AbortController cancel or browser navigation),\nNode's Http2ServerResponse emits 'close' with writableFinished === true\neven though nothing was written. isCompleted() relied on\nwritableFinished alone, so the !isCompleted filter swallowed the event\nand request.events.aborted$ never fired — consumers (route handlers,\nauth providers) could not observe HTTP/2 client aborts. HTTP/1 was\nunaffected because writableFinished stays false there.\n\nTreat a request as completed only when writableFinished &&\nwritableEnded: writableEnded only becomes true once the server actually\nended the response, and is truthful on both protocols and for abrupt\nTCP-level disconnects.\n\nAdds integration coverage in http2_protocol.test.ts driving a real\nHTTP/2 TLS session that resets the stream while the handler is pending,\nplus a control test asserting completed$ (and not aborted$) on normal\ncompletion.\n\nCo-authored-by: Claude Fable 5 <[email protected]>","sha":"a19e27a5dc885a40a31998f728c450d8aee27289"}}]}] BACKPORT--> Co-authored-by: Larry Gregory <[email protected]> Co-authored-by: Claude Fable 5 <[email protected]>
…uests (#285244) (#285383) # Backport This will backport the following commits from `main` to `9.5`: - [[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests (#285244)](#285244) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Larry Gregory","email":"[email protected]"},"sourceCommit":{"committedDate":"2026-08-17T11:55:38Z","message":"[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests (#285244)\n\n## Human summary\n\nImproves the detection of completed requests by additionally checking\nfor `res.writableEnded`. This works around a quirk in Node.js when\nrunning in http/2, which is resolved in 26+:\nhttps://github.com/nodejs/node/pull/63249.\n\nChecking for `res.writableEnded` does not change anything when running\nin http/1, as this is always true when `res.writableFinished`. This\nchanges behavior for http/2 by properly detecting aborted requests.\n\nThis should result in downstream consumers having a more reliable signal\nfor when a client request is aborted, allowing them to cancel jobs such\nas in-flight ES requests regardless of http protocol.\n\nWe detected this while writing scout tests for\nhttps://github.com//pull/285153\n\n## AI description\nWhen an HTTP/2 client destroys a stream mid-request (RST_STREAM /\nNGHTTP2_CANCEL, e.g. an AbortController cancel or browser navigation),\nNode's Http2ServerResponse emits 'close' with writableFinished === true\neven though nothing was written. isCompleted() relied on\nwritableFinished alone, so the !isCompleted filter swallowed the event\nand request.events.aborted$ never fired — consumers (route handlers,\nauth providers) could not observe HTTP/2 client aborts. HTTP/1 was\nunaffected because writableFinished stays false there.\n\nTreat a request as completed only when writableFinished &&\nwritableEnded: writableEnded only becomes true once the server actually\nended the response, and is truthful on both protocols and for abrupt\nTCP-level disconnects.\n\nAdds integration coverage in http2_protocol.test.ts driving a real\nHTTP/2 TLS session that resets the stream while the handler is pending,\nplus a control test asserting completed$ (and not aborted$) on normal\ncompletion.\n\nCo-authored-by: Claude Fable 5 <[email protected]>","sha":"a19e27a5dc885a40a31998f728c450d8aee27289","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Security","release_note:skip","backport:all-open","v9.6.0"],"title":"[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests","number":285244,"url":"https://github.com/elastic/kibana/pull/285244","mergeCommit":{"message":"[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests (#285244)\n\n## Human summary\n\nImproves the detection of completed requests by additionally checking\nfor `res.writableEnded`. This works around a quirk in Node.js when\nrunning in http/2, which is resolved in 26+:\nhttps://github.com/nodejs/node/pull/63249.\n\nChecking for `res.writableEnded` does not change anything when running\nin http/1, as this is always true when `res.writableFinished`. This\nchanges behavior for http/2 by properly detecting aborted requests.\n\nThis should result in downstream consumers having a more reliable signal\nfor when a client request is aborted, allowing them to cancel jobs such\nas in-flight ES requests regardless of http protocol.\n\nWe detected this while writing scout tests for\nhttps://github.com//pull/285153\n\n## AI description\nWhen an HTTP/2 client destroys a stream mid-request (RST_STREAM /\nNGHTTP2_CANCEL, e.g. an AbortController cancel or browser navigation),\nNode's Http2ServerResponse emits 'close' with writableFinished === true\neven though nothing was written. isCompleted() relied on\nwritableFinished alone, so the !isCompleted filter swallowed the event\nand request.events.aborted$ never fired — consumers (route handlers,\nauth providers) could not observe HTTP/2 client aborts. HTTP/1 was\nunaffected because writableFinished stays false there.\n\nTreat a request as completed only when writableFinished &&\nwritableEnded: writableEnded only becomes true once the server actually\nended the response, and is truthful on both protocols and for abrupt\nTCP-level disconnects.\n\nAdds integration coverage in http2_protocol.test.ts driving a real\nHTTP/2 TLS session that resets the stream while the handler is pending,\nplus a control test asserting completed$ (and not aborted$) on normal\ncompletion.\n\nCo-authored-by: Claude Fable 5 <[email protected]>","sha":"a19e27a5dc885a40a31998f728c450d8aee27289"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/285244","number":285244,"mergeCommit":{"message":"[Core] Fix KibanaRequest aborted$ never emitting for HTTP/2 requests (#285244)\n\n## Human summary\n\nImproves the detection of completed requests by additionally checking\nfor `res.writableEnded`. This works around a quirk in Node.js when\nrunning in http/2, which is resolved in 26+:\nhttps://github.com/nodejs/node/pull/63249.\n\nChecking for `res.writableEnded` does not change anything when running\nin http/1, as this is always true when `res.writableFinished`. This\nchanges behavior for http/2 by properly detecting aborted requests.\n\nThis should result in downstream consumers having a more reliable signal\nfor when a client request is aborted, allowing them to cancel jobs such\nas in-flight ES requests regardless of http protocol.\n\nWe detected this while writing scout tests for\nhttps://github.com//pull/285153\n\n## AI description\nWhen an HTTP/2 client destroys a stream mid-request (RST_STREAM /\nNGHTTP2_CANCEL, e.g. an AbortController cancel or browser navigation),\nNode's Http2ServerResponse emits 'close' with writableFinished === true\neven though nothing was written. isCompleted() relied on\nwritableFinished alone, so the !isCompleted filter swallowed the event\nand request.events.aborted$ never fired — consumers (route handlers,\nauth providers) could not observe HTTP/2 client aborts. HTTP/1 was\nunaffected because writableFinished stays false there.\n\nTreat a request as completed only when writableFinished &&\nwritableEnded: writableEnded only becomes true once the server actually\nended the response, and is truthful on both protocols and for abrupt\nTCP-level disconnects.\n\nAdds integration coverage in http2_protocol.test.ts driving a real\nHTTP/2 TLS session that resets the stream while the handler is pending,\nplus a control test asserting completed$ (and not aborted$) on normal\ncompletion.\n\nCo-authored-by: Claude Fable 5 <[email protected]>","sha":"a19e27a5dc885a40a31998f728c450d8aee27289"}}]}] BACKPORT--> Co-authored-by: Larry Gregory <[email protected]> Co-authored-by: Claude Fable 5 <[email protected]>
The --plugin-path for the security functional-test plugin was appended to the shared `pki` base config, so every suite derived from it booted the plugin — including the PKI login specs, which never call its routes. Adds a `pki_stress` config set that spreads `pkiConfig`, keeps http2, and carries the plugin path, and moves the HTTP/2 stream-cancel suite to test/scout_pki_stress/api/. Scout resolves the server config from the test/scout_<name> directory component, so scoping it required the move. The `pki` set is restored to its previous content. This matters slightly beyond boot cost: the login specs authenticate via PKI, and the plugin patches PKIAuthenticationProvider.authenticate. It's a header-gated pass-through, but those specs are better off exercising an unmodified auth provider. Also decouples the API fixtures from the UI suite — FIRST_CLIENT_P12 now lives in the API suite's own constants rather than being imported across the two test trees. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
hammad-nasir-elastic
left a comment
There was a problem hiding this comment.
Core changes LGTM. Spot checked new tests as well.
💛 Build succeeded, but was flaky
Failed CI StepsMetrics [docs]
Test Failures
History
|
|
Starting backport for target branches: 8.19, 9.4, 9.5 |
💔 Some backports could not be created
Note: Successful backport PRs will be merged automatically after passing CI. Manual backportTo create the backport manually run: Questions ?Please refer to the Backport tool documentation |
💚 All backports created successfully
Note: Successful backport PRs will be merged automatically after passing CI. Questions ?Please refer to the Backport tool documentation |
…ction (#285153) (#286787) # Backport This will backport the following commits from `main` to `9.5`: - [[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)](#285153) <!--- Backport version: 9.6.6 --> ### 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-24T09:23:24Z","message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Team:Security","backport:all-open","reviewer:scout","v9.6.0"],"title":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction","number":285153,"url":"https://github.com/elastic/kibana/pull/285153","mergeCommit":{"message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/285153","number":285153,"mergeCommit":{"message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e"}}]}] BACKPORT--> Co-authored-by: Jeramy Soucy <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Larry Gregory <[email protected]>
…ction (#285153) (#286786) # Backport This will backport the following commits from `main` to `9.4`: - [[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)](#285153) <!--- Backport version: 9.6.6 --> ### 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-24T09:23:24Z","message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Team:Security","backport:all-open","reviewer:scout","v9.6.0"],"title":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction","number":285153,"url":"https://github.com/elastic/kibana/pull/285153","mergeCommit":{"message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/285153","number":285153,"mergeCommit":{"message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e"}}]}] BACKPORT--> Co-authored-by: Jeramy Soucy <[email protected]> Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Larry Gregory <[email protected]>
|
Looks like this PR has backport PRs but they still haven't been merged. Please merge them ASAP to keep the branches relatively in sync. |
…astic#285153) Scout's HTTP/2 server config (`http2: true`, elastic#274000) was not backported to 8.19, so the `scout_pki_stress` suite is unbuildable on this branch. Drop the 7 Scout files and replace them with a direct FTR port of the same deterministic pre-auth-hold test: - `tests/pki/pki_http2_stress.ts` — parks a request inside PKIAuthenticationProvider.authenticate via the test_endpoints pre-auth hold, RSTs the HTTP/2 stream, confirms socket degradation, releases the hold, and asserts the session survives the follow-up request. Same four-step logic as the Scout spec; only the harness glue differs (retry.tryForTime/tough-cookie vs. expect.poll/findSessionCookie). - `pki.http2.stress.config.ts` — extends pki.config with configureHTTP2 and debug-level security logging. - `ftr_platform_stateful_configs.yml` — registers the new config. `init_routes.ts` (+230 lines, pre-auth hold machinery) is retained; it is what makes the test deterministic. The production fix in pki.ts is unchanged. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
… with plain state The cherry-pick of elastic#285153 brought main's test code verbatim, including a call to sessionMock.createValue(). On main, pki.ts authenticate() takes SessionValue<ProviderState>; on 8.19 it still takes ProviderState directly. The sessionMock import was also missing from the cherry-pick, so tsc reported: "Cannot find name 'sessionMock'" (TS2304). Replace sessionMock.createValue({ state: ... }) with a plain ProviderState object, matching the pattern used by all other authenticateViaState tests in this file on 8.19. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
…uction (#285153) (#286791) # Backport This will backport the following commits from `main` to `8.19`: - [[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)](#285153) <!--- Backport version: 11.0.2 --> ### 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-24T09:23:24Z","message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Team:Security","backport:all-open","reviewer:scout","v9.6.0"],"title":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction","number":285153,"url":"https://github.com/elastic/kibana/pull/285153","mergeCommit":{"message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/285153","number":285153,"mergeCommit":{"message":"[Security] Fix PKI session invalidation on HTTP/2 stream destruction (#285153)\n\n## Summary\n\nFixes #258232.\n\nPKI sessions were being unexpectedly invalidated on Kibana 9.x when\n`server.protocol: http2` was active (the new default when `ssl.enabled:\ntrue`). Setting `server.protocol: http1` was the customer workaround.\n\n### Root cause\n\nWhen an HTTP/2 stream is destroyed mid-request — by an `AbortController`\ncancel, browser navigation abort, or any other cause that sends\n`RST_STREAM` — Node.js clears the stream's session reference\n(`stream.session = undefined`). The `Http2ServerRequest.socket` proxy\nthen falls back from the TLS socket to the stream itself, causing every\n`instanceof TLSSocket` check in `KibanaSocket` to return `false`. The\nresult:\n\n- `socket.authorized` → `undefined` (not `true` or `false`)\n- `socket.getPeerCertificate()` → `null`\n\nThe guard in `authenticateViaState` (pki.ts line ~208) was written for\nexactly this \"connection closed\" case:\n\n```ts\n// Before (buggy):\nif (peerCertificate === null && request.socket.authorized) {\n // return non-401 soft fail — session preserved\n}\n```\n\nOn HTTP/1.1 a closed `TLSSocket` keeps `authorized === true`, so the\nguard fired correctly. On HTTP/2, `authorized === undefined` is falsy,\nso the guard was silently skipped. The code then fell through to the\ntoken-invalidation block, revoked the ES access token, and returned\n`notHandled()` — which `authenticate()` converted to\n`Boom.unauthorized()`. `updateSessionValue()` saw the 401 on an owned\nsession and **deleted the server-side session**, logging the user out.\n\n### Fix\n\nChange the guard from a truthy check to `!== false`:\n\n```ts\n// After (fixed):\nif (peerCertificate === null && request.socket.authorized !== false) {\n // return non-401 soft fail — session preserved\n}\n```\n\n`false` = cert was presented but rejected → token invalidation is\ncorrect.\n`undefined` = socket state is unknowable (HTTP/2 stream destroyed) →\ntreat same as `true`, preserve session.\n`true` = socket explicitly authorized → original behaviour unchanged.\n\nAlso improves the diagnostic log in `authenticateViaPeerCertificate` to\ndistinguish `false` (rejected cert) from `undefined` (unknown state),\nmaking production logs actionable.\n\n### Why this is new in 9.x\n\n`http_config.ts` defaults `server.protocol` to `http2` when\n`ssl.enabled: true` — a silent breaking change from 8.x. HTTP/1.1 is\nunaffected because each request has a dedicated `TLSSocket` that keeps\n`authorized === true` as a plain property after the connection closes.\n\n## Test plan\n\n- [x] New regression test in `pki.test.ts`: `does not invalidate token\nor destroy session when socket state is unknown due to HTTP/2 stream\ndestruction` — fails on `main`, passes after fix\n- [x] New test in `pki.test.ts`: `does not handle requests when socket\nauthorization state is unknown due to HTTP/2 stream destruction` —\ncovers `login` and session-less `authenticate`, verifies correct log\nmessage\n- [x] All 40 existing PKI unit tests continue to pass\n\nRun tests:\n```\nnode scripts/jest --config x-pack/platform/plugins/shared/security/jest.config.js --testPathPattern=\"authentication/providers/pki\" --no-coverage\n```\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\n---\n\n## FTR stress test (regression coverage for RST_STREAM)\n\n`pki.test.ts` unit tests are the authoritative regression lock — they're\ndeterministic and always catch a revert. An additional FTR test is\nincluded to provide end-to-end coverage of the exact failure mode:\n\n\n**`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`**\nRegistered in `pki.http2.stress.config.ts` and added to\n`ftr_platform_stateful_configs.yml`.\n\n### Why supertest can't cover this\n\nThe existing `pki_auth.ts` concurrent-request test (5 streams) uses\nsupertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal —\nsupertest has no way to emit it. That is why the bug wasn't caught by\nthe existing concurrent-auth test.\n\n### What the FTR test does\n\n1. Establishes a PKI session via supertest (HTTP/1.1 — simple,\nreliable).\n2. Opens a raw `http2.connect()` connection to Kibana with the same PKI\nclient certificate.\n3. Fires 20 concurrent slow requests against the test endpoints plugin\n(10s hold, giving Kibana time to enter the async session-lookup window).\n4. After 150ms, cancels all 20 streams with `RST_STREAM NGHTTP2_CANCEL`.\n5. Sends a normal (non-cancelled) `GET /internal/security/me` on the\nsame HTTP/2 session.\n6. Asserts `200` + `username === 'first_client'` — the session was NOT\ninvalidated.\n\nBefore the fix: at least one RST_STREAM would destroy the stream during\nthe auth lifecycle, causing `authorized → undefined`, which bypassed the\nguard, invalidated the ES token, and returned 401.\nAfter the fix: all RST_STREAM frames are handled gracefully; the session\nsurvives.\n\n### Stability note\n\nAfter the fix is applied, the test is unconditionally stable — the guard\nchange means RST_STREAM never reaches the token-invalidation path\nregardless of timing. The test cannot produce a false positive (failing\nwhen the fix is present). Before the fix it might occasionally miss the\nnarrow timing window (false negative), but this is acceptable for a\nregression test: it cannot regress silently if the fix is reverted,\nbecause the unit test in `pki.test.ts` will always catch it.\n\n## Release note\nFixes an issue where PKI authentication sessions were unexpectedly\ninvalidated when in-flight requests were cancelled over HTTP/2, logging\nusers out. This affects deployments on server.protocol: http2, which\nbecame the default in 9.0.0 when TLS is enabled.\n\n---------\n\nCo-authored-by: Claude Sonnet 4.6 <[email protected]>\nCo-authored-by: kibanamachine <[email protected]>\nCo-authored-by: Larry Gregory <[email protected]>","sha":"a0520264b3fca9841b7e85f9c4b1996eb038b60e"}},{"url":"https://github.com/elastic/kibana/pull/286786","number":286786,"branch":"9.4","state":"OPEN"},{"url":"https://github.com/elastic/kibana/pull/286787","number":286787,"branch":"9.5","state":"OPEN"}]}] BACKPORT--> --- ## 8.19 backport adjustments ### Scout test suite replaced with FTR equivalent On `main`, the regression suite lives in `x-pack/platform/plugins/shared/security/test/scout_pki_stress/` and uses Scout's HTTP/2 server configuration (`http2: true` in the server config, landed in #274000). That Scout HTTP/2 plumbing was not backported to 8.19 (`backport:skip` on #274000; backporting it would require ~19 files of shared Scout code). PR #286876 (the Scout HTTP/2 backport) is being closed as a result. The Scout files have been dropped from this backport. The test coverage is preserved via a direct FTR port of the same deterministic test: **`x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.ts`** The test logic is identical to the Scout spec — same four steps (login / park inside `PKIAuthenticationProvider.authenticate` via the pre-auth hold / RST until socket is confirmed degraded / release and follow-up). Only the harness glue differs (`retry.tryForTime` replaces `expect.poll`; `tough-cookie` `parseCookie` replaces `findSessionCookie` whose root barrel is absent on 8.19). The pre-auth hold infrastructure in `init_routes.ts` is retained; it is what makes the test deterministic. Note: the `main` PR description's "FTR stress test" section describes a timing-based design (commit `3d344b8d46c5`, which fired 20 slow requests and RST'd them after 150ms). That commit was removed in `24d55c0727ac` in favour of the Scout API test and the description was never updated. The FTR test in this 8.19 backport uses the deterministic pre-auth-hold design, not the timing-based one. `pki.http2.stress.config.ts` registered in `ftr_platform_stateful_configs.yml`. --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: kibanamachine <[email protected]>
…ests (#285344) ## Summary Fixes the root cause of #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 #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 #285153 - **#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]>
…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-->
…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-->
…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]>
Summary
Fixes #258232.
PKI sessions were being unexpectedly invalidated on Kibana 9.x when
server.protocol: http2was active (the new default whenssl.enabled: true). Settingserver.protocol: http1was the customer workaround.Root cause
When an HTTP/2 stream is destroyed mid-request — by an
AbortControllercancel, browser navigation abort, or any other cause that sendsRST_STREAM— Node.js clears the stream's session reference (stream.session = undefined). TheHttp2ServerRequest.socketproxy then falls back from the TLS socket to the stream itself, causing everyinstanceof TLSSocketcheck inKibanaSocketto returnfalse. The result:socket.authorized→undefined(nottrueorfalse)socket.getPeerCertificate()→nullThe guard in
authenticateViaState(pki.ts line ~208) was written for exactly this "connection closed" case:On HTTP/1.1 a closed
TLSSocketkeepsauthorized === true, so the guard fired correctly. On HTTP/2,authorized === undefinedis falsy, so the guard was silently skipped. The code then fell through to the token-invalidation block, revoked the ES access token, and returnednotHandled()— whichauthenticate()converted toBoom.unauthorized().updateSessionValue()saw the 401 on an owned session and deleted the server-side session, logging the user out.Fix
Change the guard from a truthy check to
!== false:false= cert was presented but rejected → token invalidation is correct.undefined= socket state is unknowable (HTTP/2 stream destroyed) → treat same astrue, preserve session.true= socket explicitly authorized → original behaviour unchanged.Also improves the diagnostic log in
authenticateViaPeerCertificateto distinguishfalse(rejected cert) fromundefined(unknown state), making production logs actionable.Why this is new in 9.x
http_config.tsdefaultsserver.protocoltohttp2whenssl.enabled: true— a silent breaking change from 8.x. HTTP/1.1 is unaffected because each request has a dedicatedTLSSocketthat keepsauthorized === trueas a plain property after the connection closes.Test plan
pki.test.ts:does not invalidate token or destroy session when socket state is unknown due to HTTP/2 stream destruction— fails onmain, passes after fixpki.test.ts:does not handle requests when socket authorization state is unknown due to HTTP/2 stream destruction— coversloginand session-lessauthenticate, verifies correct log messageRun tests:
🤖 Generated with Claude Code
FTR stress test (regression coverage for RST_STREAM)
pki.test.tsunit tests are the authoritative regression lock — they're deterministic and always catch a revert. An additional FTR test is included to provide end-to-end coverage of the exact failure mode:x-pack/platform/test/security_api_integration/tests/pki/pki_http2_stress.tsRegistered in
pki.http2.stress.config.tsand added toftr_platform_stateful_configs.yml.Why supertest can't cover this
The existing
pki_auth.tsconcurrent-request test (5 streams) uses supertest, which speaks HTTP/1.1. RST_STREAM is an HTTP/2-only signal — supertest has no way to emit it. That is why the bug wasn't caught by the existing concurrent-auth test.What the FTR test does
http2.connect()connection to Kibana with the same PKI client certificate.RST_STREAM NGHTTP2_CANCEL.GET /internal/security/meon the same HTTP/2 session.200+username === 'first_client'— the session was NOT invalidated.Before the fix: at least one RST_STREAM would destroy the stream during the auth lifecycle, causing
authorized → undefined, which bypassed the guard, invalidated the ES token, and returned 401.After the fix: all RST_STREAM frames are handled gracefully; the session survives.
Stability note
After the fix is applied, the test is unconditionally stable — the guard change means RST_STREAM never reaches the token-invalidation path regardless of timing. The test cannot produce a false positive (failing when the fix is present). Before the fix it might occasionally miss the narrow timing window (false negative), but this is acceptable for a regression test: it cannot regress silently if the fix is reverted, because the unit test in
pki.test.tswill always catch it.Release note
Fixes an issue where PKI authentication sessions were unexpectedly invalidated when in-flight requests were cancelled over HTTP/2, logging users out. This affects deployments on server.protocol: http2, which became the default in 9.0.0 when TLS is enabled.