Security: firewall command injection, darling.json ACL, and MCP Host-header guard (#1646, #1647, #1648) - #1655
Merged
Merged
Conversation
…er guard Four findings from the 2026-07 maintenance security review (#1643). #1646 (HIGH) - ReconcileEndpointFirewallAsync interpolated darling.json's allowFrom UNQUOTED into the PowerShell -Command string that creates the scoped firewall rule, gated only by a blank check. It was the one BuildFirewallEnableCommand caller that never parsed the value; the toggle verbs load config with DarlingConfig.Load, which never calls Validate(), so nothing upstream caught it either. Two layers now: the verb parses allowFrom as a CIDR and passes the parser's canonical output, refusing outright (no firewall change, and deliberately no printed handoff command) on a parse failure; and both firewall builders single-quote and escape every value they interpolate, so they are safe regardless of caller. Emitted commands are byte-for-byte unchanged for real rule names and CIDRs. #1647 (HIGH) - darling.json holds every server's encryptedPassword plus the MCP and web tokens under DPAPI LocalMachine scope with a published entropy constant, so read access IS the secret - but it never got an ACL, and the documented install under C:\ inherits BUILTIN\Users: Read & Execute. The service now hardens the resolved config path at startup with the posture the admin/viewer credentials get (allowInteractiveRead: true, since the Viewer and CLI verbs run as the interactive operator), install-darling.ps1 applies the identical ACL after service creation, and a still-readable file logs Critical rather than crashing the service. #1648 (MEDIUM) - neither MCP host checked the Host header, so the tokenless loopback bind was reachable via DNS rebinding - and that surface now carries view CRUD, add_servers/remove_server, and alert writes. The decision moves into a shared HostHeaderGuard in PerformanceMonitor.Common and installs as the FIRST middleware in both bind modes on Darling's MCP host and Lite's, mirroring the web host (whose behavior and tests are untouched). Lite (LOW) - DataImportService built COPY ... TO '{path}' unescaped while every sibling routes through DuckDbInitializer.EscapeSqlPath; an apostrophe in the import folder made the COPY fail to parse and the per-table catch swallow it, so the import reported success having flushed nothing. Tests pin the wiring, not just the decisions: #1648 was a wiring omission that a pure-function test would have passed on the vulnerable build, so the guard-installed-first assertions parse the shipped host sources. Darling.Tests 3057 passed / 147 skipped, Lite.Tests 1465 passed / 0 skipped. Solution builds with 0 errors and no new warnings (621, unchanged baseline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Empirically verified: IPNetwork.TryParse("192.168.1.5/24") succeeds and
yields 192.168.1.0/24. That makes passing the parser's output rather than
the caller's string load-bearing, not merely defensive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hardening # Conflicts: # CHANGELOG.md
This was referenced Jul 25, 2026
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 #1646, #1647, #1648. Four findings from the 2026-07 maintenance security review (#1643), all in the same blast radius: what a local user, or a browser running on the monitoring host, can reach.
#1646 (HIGH) - PowerShell command injection via
allowFromReconcileEndpointFirewallAsyncreadweb.network.allowFrom(or the MCP equivalent) out ofdarling.jsonand interpolated it unquoted into the PowerShell-Commandstring that creates the scoped firewall rule. The only gate wasstring.IsNullOrWhiteSpace. It was the oneBuildFirewallEnableCommandcaller that never parsed the value first - every other call site passes a canonicalizedIPNetwork.ToString(), and--configure-networkvalidates before writing - andToggleEndpointAsyncloads config withDarlingConfig.Load, which deserializes and never callsValidate(), so nothing upstream caught it either.Failure scenario closed: an
allowFromof10.0.0.0/8; <command>executed that command. Elevated (the documented way to run these verbs) it ran directly; non-elevated, the code printed the fully-injected command and told the operator to paste it into an elevated PowerShell - same outcome, via the human. Whoever can writedarling.json, or setDARLING_CONFIG(honored with no path restriction), got code execution in the elevating operator's context.Fix, two layers - either alone leaves a sharp edge:
allowFromas a CIDR (ClassifyAllowFrom) and passes the parser's canonical output, so nothing unvalidated is carried through even on the valid path. On a parse failure it refuses: no firewall change, and deliberately no printed command, because a handoff executes the injection just as surely as running it.BuildFirewallEnableCommand/BuildFirewallDisableCommandnow single-quote and escape every value they interpolate (PowerShell escapes'by doubling), so the builders are safe regardless of what any present or future caller hands them.Real rule names contain no quote, so the emitted commands are byte-for-byte unchanged. Worth noting:
IPNetwork.TryParsemasks host bits rather than rejecting them (192.168.1.5/24becomes192.168.1.0/24), verified empirically, which is exactly why the canonical form and not the caller's string is what reaches-RemoteAddress.#1647 (HIGH) -
darling.jsonwas never ACL-hardenedIt holds every monitored server's
encryptedPassword, the MCP bearer token, the web dashboard access token, and in BYO mode the store connection string - all DPAPI LocalMachine scope with an entropy constant published in this open-source repo. Anything that can read the file can unprotect the lot. That is by design;DarlingFileSecuritysays so itself ("the file ACL is therefore the real access boundary"). The config just never got that ACL: every harden call site targeted the credential files under the data directory, while the config sat beside the binary.Failure scenario closed: the README's recommended install extracts the zip to
C:\PerformanceMonitorDarling, and a folder created directly underC:\inheritsBUILTIN\Users: Read & Executefrom the root DACL. Any local unprivileged user could read it, decrypt every SQL Server password, and lift the tokens that unlock the MCP write surface and the web dashboard - and, where the install directory was user-writable, plant the payload for #1646.Fix: the service hardens the resolved config path at startup with the same posture the admin/viewer credentials get (
allowInteractiveRead: true- the Viewer and the CLI verbs run as the interactive operator and must still read it);install-darling.ps1applies the identical ACL right after service creation, which is when theNT SERVICEvirtual account first has a SID to grant; and a file that is still readable by Users/Authenticated Users/Everyone after the attempt logsCriticalrather than crashing a monitoring service over a permissions problem. The newIsReadableByOrdinaryUserscheck keys onReadDataspecifically, not the compositeReadmask, so a metadata-only ACE does not cry wolf.#1648 (MEDIUM, plus Lite parity) - MCP hosts had no Host-header allowlist
The web dashboard got this guard in #1576, with the reasoning written into the code: a loopback surface is tokenless, so a browser on the host that loads attacker content can be DNS-rebound to
127.0.0.1and read/write the whole surface same-origin. That applies identically to the MCP host at :5152 in its default loopback configuration, which installs no auth middleware at all - and that surface is no longer read-only, having gainedDarlingMcpCustomViewTools(view CRUD),DarlingMcpServerAdminTools(add_servers/remove_server), and alert-config writes.Failure scenario closed: an operator browsing from the Darling host loads an attacker page; the attacker's domain re-resolves to 127.0.0.1 and their JavaScript POSTs to the MCP endpoint, reading the monitoring store and calling the write tools. No token needed. The
application/jsoncontent type does not save it - under a rebind the browser treats the request as same-origin, so no CORS preflight applies, andModelContextProtocol.AspNetCore1.4.0 does not add the check itself (per the MCP spec it is the application's job).Fix: the decision moves into a shared
HostHeaderGuardinPerformanceMonitor.Common- the library both apps already reference, so there is one implementation, not a per-app copy - reachable fromDarlingHostBindingwhere the rest of the hosts' bind/auth helpers live. It is installed as the first middleware, in both bind modes, on Darling's MCP host and on Lite's, mirroringDarlingWebHostService.cs:399-408. The web host's behavior is byte-for-byte unchanged: itsIsAllowedHostis now a forwarder and its existingDarlingWebAuthTestsmatrix is untouched, which is the proof.Lite is loopback-only, single-user, and holds no service-account privilege, so its exposure is informational. Fixed in the same change anyway - a guard that exists in one app's copy and not the other's is the drift this codebase keeps paying for.
Lite (LOW) - unescaped export path
DataImportService.FlushOldDatabasebuiltCOPY (...) TO '{path}'by interpolating the import folder raw, while every sibling COPY (ArchiveService,ParquetCompaction) routes the same value throughDuckDbInitializer.EscapeSqlPath.Failure scenario closed: an apostrophe is legal in a Windows path (
C:\Users\O'Brien\...), so it closed the SQL literal, the COPY failed to parse, and the per-table catch swallowed it as a warning - the import reported success having silently flushed nothing. Verified against a real DuckDB: the unescaped form raisesParser Error: syntax error at or near "Brien"where the escaped form writes the file.Tests
Added to the existing suites, and they pin the things that actually regress:
ClassifyAllowFrom- the accept/refuse matrix, and a property test that whatever the verdict accepts re-parses as a CIDR and carries no shell metacharacter. That makes "a non-CIDRallowFromnever reaches a firewall command" true by construction rather than by enumerating the payloads someone thought of.-RemoteAddressstays inside one balanced single-quoted literal with its own quotes doubled, plus an exact-string pin that real inputs produce the unchanged command.HostHeaderGuard- the full accept/reject matrix in both modes, in both suites, plus a forwarders-agree check so the web host cannot diverge.HostHeaderGuardTestsin both suites parse the shipped host sources (copied beside the test binary by the csproj, the same Link+copy deliveryProvisionRolesAclDriftTestsuses) and assert the guard is the first_app.Useafterbuilder.Build(), sits ahead of theif (networkMode)gates, and precedesMapMcp. Negative-checked: removing Lite's middleware makes the test fail as intended.IsReadableByOrdinaryUsersagainst real Windows DACLs: an explicitUsersread ACE is caught, hardening clears it,Authenticated UsersandEveryoneare caught too, and a metadata-only grant or a deny ACE is correctly ignored.Results:
Darling.Tests3057 passed / 0 failed / 147 skipped (theDARLING_TEST_PG-gated live tests).Lite.Tests1465 passed / 0 failed / 0 skipped.Installer.Testsdeliberately not run (it touches live SQL Servers).Build: 0 warnings, 0 errors on a clean
--no-incrementalbuild of the whole solution.origin/devmoved while this was in progress - the maintenance PR (#1645, from the same #1643 review that filed these issues) merged in, and it is what took the build to zero warnings. This branch was cut from the older dev, sodevhas been merged in here and everything re-verified on top of it: the zero-warning figure above, and both test runs below, are against the merged result, not the older base. Merge was conflict-free, including the CHANGELOG (both entries are present under[Unreleased]->Fixed).Notes
ParquetCompaction.EscapeSqlPathstays a private local copy rather than forwarding toDuckDbInitializer. That file's header states it is compiled intotools/CompactionReproas well and must stay free of project dependencies - confirmed byCompactionRepro.csprojline 23, which<Compile Include>s it. Consolidating would break the reproducer. A comment now records why, and the source pin covers all three call sites regardless.encryptedPasswordvalues into the already-hardened credential directory, which would remove the dependency on install-location ACLs entirely. That is a config-format migration, not a hardening pass.ViewerWave2Tests.BlockedRow_EventTimeLocal_FormatsTheStoredUtcInLocalTimefailed once across four full Darling runs and passes in isolation. Cause is shared mutable global state -ViewerTimeHelperexposes a settable static UTC offset and display mode thatForDisplayreads, so a test class flipping either can race this one under xUnit's parallel collections. Left alone here; fixing it means choosing a collection-serialization strategy for the existing Viewer suite, which does not belong in a security PR.🤖 Generated with Claude Code