Repository navigation
Plan capture no longer trusts a plan header found anywhere in the log, only one at a real line start (#4008) - #4015
Merged
Conversation
… a statement can no longer forge a plan onto another query (#4008) Both PgPlanLogParser.s_planBlock (the C# parser, RDS log-API route) and PgPlanCaptureCollector's self-hosted regexp_matches matched auto_explain's "[pid] queryid LOG: duration: ... plan:" header anywhere in the log tail, with no timestamp check. PostgreSQL echoes a rejected statement back verbatim in its STATEMENT: companion, tab-indented continuations included, so a user who could run SQL on a monitored target could plant that header inside their own query text and have it read back as a real auto_explain block - choosing the query id and duration it attaches to, and forging the plan JSON itself. Both patterns now anchor to a genuine physical line start (RegexOptions.Multiline / SQL's 'n' flag) and require a real timestamp there, matching the log_line_prefix shape auto_explain actually renders. The managed-prefix branch also excludes '[' from the gap before the pid bracket rather than using a lazy wildcard, closing a backtracking path that would otherwise let a real bracket earlier on the same forged line be skipped in favor of a later, forged one. Found by #3996's round-2 security review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
erikdarlingdata
added a commit
that referenced
this pull request
Sep 23, 2026
…ntries in their sections (#4080) Adds 42 entries and 42 link refs (#3992, #3995, #3996, #3998, #4001, #4002, #4003, #4007, #4010, #4011, #4013, #4015, #4020, #4022, #4025, #4029, #4030, #4031, #4036, #4038, #4039, #4040, #4044, #4047, #4048, #4049, #4050, #4051, #4055, #4061, #4063, #4064, #4065, #4066, #4067, #4068, #4069, #4070, #4071, #4073, #4074, #4078). Each PR's entry was buffered, and this lands every entry whose PR was merged on origin/dev when it ran. #3989 left 26 entries under bare 'Changed' and 'Fixed' lines above '### Added'. They move into '### Changed' and '### Fixed', below the new entries, and one blank line stays under [Unreleased]. Claude-Session: https://claude.ai/code/session_01Ua31ugERL5DmhFVRtf6keQ Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4008.
Why
Found by #3996's round-2 security review.
PgPlanLogParser.s_planBlock(the C# parser, RDS log-APIroute) and
PgPlanCaptureCollector's self-hostedregexp_matchesboth matched auto_explain's planheader -
[pid] queryid LOG: duration: ... plan:- anywhere in the log tail, with no requirementthat a real timestamp precede it. PostgreSQL echoes a rejected statement back verbatim in its
STATEMENT:companion after a syntax error, tab-indented continuation lines included, so a user whocan run SQL on a monitored target could write the header into their own query text (in a comment)
and have PostgreSQL's own echo read back as a genuine auto_explain block - choosing the query id and
duration the forged plan attaches to, and the plan JSON itself. Confirmed live on a throwaway rig:
the pre-fix SQL returned
Row { QueryId = 7, ..., PlanJson = {"Plan":{"Node Type":"Seq Scan","Relation Name":"HUNTER2_LEAK","Alias":"card 4111111111111111"}} }from exactlythis PoC.
What changes
PgPlanLogParser.s_planBlockandPgPlanCaptureCollector's SQL both now anchor to the start of agenuine physical log line (
RegexOptions.Multiline/ SQL's'n'flag) and require a real\d{4}-\d\d-\d\d ...timestamp there. Forged text is never the first character of a raw physicalline - a real line always opens with
log_line_prefixand a continuation always opens with a tab.%t:%r:%u@%d:[%p]:) excludes[from the gap before thepid bracket (
[^\[\n]*) instead of a lazy[^\n]*?. The lazy form still lets a REAL bracket earlieron the same line be backtracked past to reach a forged one later on it, once the tail pattern fails
to match right after the real bracket - a distinct sub-case, pinned by its own regression test
(
AForgedHeaderBehindARealColonPrefixedBracket_StillYieldsNoPlan), which fails if that hardening isreverted to the lazy form while the plain PoC test stays green.
as before this fix - it never supported the colon family, and this is a security fix, not a
capability addition.
Darling/Darling.Tests/PgLogEventsPipelineTests.cs'sTheTailerExtraction_LeftBothSiblingsSqlByteIdenticalpin is updated to the fixed SQL text (the deadlock and log-events SQL it also pins are untouched by
this issue).
Darling/Darling.Tests/PgPlanCaptureLiveTests.cs: a live test against a real target withauto_explainactually writing to a real log file, proving the FIXED SQL still captures a real plan(correct query id, duration, top node type) while the exact forged PoC yields no row at all. Gated on
DARLING_TEST_PG_AUTOEXPLAIN(a target started withlogging_collector = onandlog_line_prefix = '%m [%p] %Q '), separate from the sharedDARLING_TEST_PGstore, becauselogging_collectoris postmaster-context and the shared store must not carry session chatter fromevery other live test into a log this test reads.
Lite parity: N/A. Lite does not capture PostgreSQL plans (SQL Server DMVs only);
PgPlanLogParserand
PgPlanCaptureCollectorlive in the sharedPerformanceMonitor.Collectorsproject and are testedfrom
Lite.Testsonly because that is where this shared library's unit tests live, not because Literuns this collector.
Filed, not fixed here: #4014 -
PgDeadlockLogParser/PgDeadlocksCollectorshare the anchoringthis PR adds, but their
[^\n]*ERROR: deadlock detectedgap is a wildcard that can cross a realfield-label boundary (a different, wider hole than anything #4008 closes), and their managed-prefix
branch has the same lazy-gap backtracking issue this PR fixes for plan capture. Out of scope here:
different collector, needs its own PoC verification and regression tests.
Test plan
Lite.Testsfull suite: 5170/5170 passed.Darling.Testsfull suite (DARLING_TEST_PG against a scratch rig): see CI / latest run below.PgPlanLogParserTests+PgPlanCaptureCollectorDefinitionTests(14 tests) pass.AForgedHeaderBehindARealColonPrefixedBracket_StillYieldsNoPlanalone fails against ananchored-but-lazy-gap intermediate version, proving it pins a distinct sub-case.
PgPlanCaptureLiveTestsfails against the original SQL, surfacing theforged
HUNTER2_LEAK/ query id 7 row read straight off a real target's log file; passesagainst the fix, with a real plan still captured correctly.
TheTailerExtraction_LeftBothSiblingsSqlByteIdentical(byte-identity pin) updated and green.🤖 Generated with Claude Code
https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv