You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(policy): harden the package broker pipe transport - #114
Hardens the default NamedPipeBrokerTransport of Devolutions.Now.Policy.Client and makes it configurable, so consumers no longer need their own pipe transports.
Default behavior (Windows)
Least-privilege access: opens the package broker pipe with PipeAccessRights.Read | Synchronize | WriteData (FILE_GENERIC_READ | FILE_WRITE_DATA) instead of GENERIC_READ | GENERIC_WRITE. This is a subset of what current and upcoming brokers grant, so the change is backward compatible. Write-attributes access is not requested because the read mode is never changed.
Limited impersonation: the server gets identification-level impersonation by default (SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION).
Server verification before any request byte is written: GetNamedPipeServerProcessId must match the running process of the DevolutionsAgent (MSI) or devolutions-agent (manual registration) service, reported by the SCM (QueryServiceStatusEx), and that service must be configured to run as LocalSystem (QueryServiceConfig). This works for standard users. When the caller can open the server process (elevated callers), its token user must also be LocalSystem, and the process handle is held until the exchange completes. Failures throw BrokerClientException with the new BrokerClientErrorKind.ServerVerificationFailed. On other platforms, verification fails closed unless disabled.
Busy retry: 503 replies carrying Retry-After are retried over a new connection, at most 3 times by default, with the delay capped at 5 s. 503 replies without Retry-After are returned as before.
Closed-connection retry: when the broker closes a connection before sending any response byte, the request is retried over a new connection. Retries share the same budget and use an exponential backoff that starts at 100 ms, capped by the same delay. A request is resent only if the write failed, meaning the broker never received the complete request, or if the request has no side effects: GET, HEAD, evaluation, status and policy validation. Other requests, such as executions and policy replacements, fail with BrokerUnavailable. Partial responses are never retried. Busy and temporarily missing pipe instances were already retried while connecting, within ConnectTimeout.
The request header and body are sent in a single write right after connecting.
Strict HTTP/1.1 framing:
the status line must be HTTP/1.1 with a status code from 100 to 599;
header field names must be HTTP tokens;
a single valid Content-Length is required and Transfer-Encoding is rejected;
excess data is detected with an EOF probe;
header and body size budgets are checked before the body buffer is allocated.
Configuration (additive API)
NamedPipeBrokerTransportOptions (record), accepted by a new NamedPipeBrokerTransport(NamedPipeBrokerTransportOptions) constructor and by BrokerClientOptions.NamedPipeTransport:
PipeName
ConnectTimeout, ResponseTimeout
MaxResponseHeaderBytes, MaxResponseBodyBytes
ImpersonationLevel
VerifyServer, ServerServiceNames
ServerAuthenticator, ServerAuthenticatorTimeout
MaxBusyRetries, MaxBusyRetryDelay
IncompleteResponseErrorKind
BrokerPipeServerAuthenticator hook with BrokerPipeServerContext. The context exposes the connected pipe, the pipe name, the endpoint, the server process id, and the held server process handle when available. The hook runs after built-in verification and before any write, and is bounded by its own deadline. Its returned IDisposable lives until the response is read, and late results are disposed.
NamedPipeBrokerTransport.Trace. BrokerClient forwards it to BrokerClient.Trace.
Existing constructors and options are unchanged. ClientExecutablePath still defaults to Environment.ProcessPath, the real process image path.
Note: new NamedPipeBrokerTransport(null) with a literal null is now ambiguous at compile time. new NamedPipeBrokerTransport() and passing a string? variable still compile. Binary compatibility is unaffected.
Validation
New unit tests cover:
access-mask selection
option defaults and validation
request encoding
response framing, including size budgets, incomplete responses, and malformed status lines and headers
Retry-After handling, resend safety, and disconnect backoff
server verification logic, using an inspector abstraction
New real named-pipe tests (Windows, Linux, macOS) cover:
the request and response round trip
bounded busy retries
connections dropped right after accept, or closed after reading the request, for safe and unsafe requests
a shared retry budget for busy replies and closed connections
partial responses, which are not retried
verification failure, with no bytes sent
authenticator success, rejection, async and synchronous timeouts, and late-result disposal
configurable timeouts and body budget
BrokerClient defaults
Windows-only tests:
a pipe with a broker-like DACL rejects a GENERIC_WRITE client but accepts the transport
the server observes the configured impersonation level
the server process id and handle are exposed
Checked manually as a non-elevated user against live LocalSystem services: the SCM status and configuration queries succeed, OpenProcess is denied as expected, and the verifier accepts the matching service.
dotnet format --verify-no-changes passes, and dotnet test passes on net10.0 locally (387 tests). net9.0 is covered by CI.
Open the package broker pipe with least-privilege access (generic read and
write data only), limit pipe client impersonation to the identification
level by default, and verify the package broker pipe server before sending
any request data: the kernel-reported server process must be the running
Devolutions Agent service process, configured to run as LocalSystem.
Retry busy package broker responses (503 with Retry-After) over a new
connection with a bounded number of attempts and a capped delay, and send
each request in a single write right after connecting.
Make the named pipe transport configurable through the additive
NamedPipeBrokerTransportOptions (also exposed as
BrokerClientOptions.NamedPipeTransport): connect and response timeouts,
response header and body size budgets checked before allocation, strict
HTTP/1.1 framing, impersonation level, server verification and service
names, an optional server authenticator hook with its own deadline, and
the error kind used for incomplete responses.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Hardens and configures the default named-pipe broker transport, adding server verification, strict response handling, bounded retries, and authentication hooks.
Changes:
Adds configurable transport security, timeouts, retries, and server authentication.
Implements Windows service/process verification and stricter HTTP framing.
Adds comprehensive unit and cross-platform pipe tests.
File
Description
WindowsBrokerServerInspector.cs
Adds Windows process, token, and service inspection.
README.md
Documents hardened transport behavior and configuration.
NamedPipeBrokerTransportOptions.cs
Defines and validates transport options.
NamedPipeBrokerTransport.cs
Implements verification, authentication, retries, and timeouts.
Devolutions.Now.Policy.Client.csproj
Enables unsafe interop and test access.
BrokerServerVerifier.cs
Verifies service process and LocalSystem identity.
BrokerPipeServerAuthenticator.cs
Adds the custom authentication API and context.
BrokerHttp.cs
Encodes requests and strictly parses framed responses.
BrokerClientOptions.cs
Exposes named-pipe transport configuration.
BrokerClientErrorKind.cs
Adds the server-verification failure kind.
BrokerClient.cs
Creates and traces the configured default transport.
BrokerBusyRetry.cs
Parses and caps Retry-After delays.
NamedPipeBrokerTransportTests.cs
Tests real pipe behavior and hardened defaults.
BrokerTransportUnitTests.cs
Tests framing, options, retries, and verification.
Require an HTTP/1.1 status line with a reason segment, and report an
authenticator's own cancellation as a server verification failure instead
of a timeout.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Transfer-Encoding is still forwarded even though this encoder always adds Content-Length. A caller that supplies Transfer-Encoding: chunked therefore emits conflicting framing, so the broker may parse or reject the body as chunked despite the transport encoding it as a fixed-length body. Treat this as another transport-owned framing header and omit it (or reject the request).
Validate timeout values against supported timer limits
This validation accepts arbitrarily large positive timeouts, but each value is later passed to CancellationTokenSource.CancelAfter, whose TimeSpan overload rejects delays above its supported timer range. For example, TimeSpan.MaxValue passes construction and then causes Send to throw ArgumentOutOfRangeException instead of applying the configured timeout. Validate the upper bound here (or implement long-delay handling); apply the same constraint to MaxBusyRetryDelay, which is passed to Task.Delay.
Drop caller-supplied Transfer-Encoding headers since the transport always
sends a fixed Content-Length, and reject timeout and retry delay options
beyond the supported timer range.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Trimming the field name turns malformed lines such as Content-Length : 2 or Content-Length: 2 into a valid framing header. That violates the strict parser contract and can make an invalid response pass framing validation; validate the raw field name as an HTTP token and reject whitespace around it instead of normalizing it.
Reject HTTP status codes outside the 100–599 range
The three-digit check still accepts status codes 600–999, although valid HTTP status codes are limited to 100–599. With strict HTTP/1.1 framing, these responses should produce InvalidResponse rather than flow into broker response handling.
Require response header field names to be HTTP tokens without surrounding
whitespace and status codes to be within 100-599. Make the test pipe server
tolerate clients that close before the connection is accepted.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the package broker closes a connection before sending any response
byte, retry over a new connection within the MaxBusyRetries budget, with
an exponential backoff starting at 100 ms and capped by MaxBusyRetryDelay.
A request is resent only when it did not fully reach the broker (the write
failed), or when it has no side effects: GET, HEAD, evaluation, status and
policy validation requests. Other requests, such as executions and policy
replacements, fail with BrokerUnavailable. Partial responses are never
retried. Busy and temporarily missing pipes were already retried while
connecting, within ConnectTimeout.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ckoff
Report I/O failures after the response started with the configured
incomplete-response error kind, treat a close after the complete response
as the end of the exchange, and let the disconnect backoff grow up to
MaxBusyRetryDelay.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the package broker server process can be opened, fail verification if
its token cannot be read instead of relying on the service configuration.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Standard users can no longer open the package broker pipe with generic write access. Package broker clients must open the pipe with `GENERIC_READ | FILE_WRITE_DATA`, as `Devolutions.Now.Policy.Client` does starting with the release that includes Devolutions/now-libraries#114, and UniGetUI starting with the release that adopts it.
The broker keeps a pipe instance listening at all times. When all connection slots are in use, or a user already holds 4 concurrent connections, the broker replies `503 Service Unavailable` with a `Retry-After` header and a `BrokerPaused` error instead of leaving the pipe busy. Clients should retry these replies after the indicated delay. At most 16 busy replies are pending at once; beyond that, further clients are disconnected without a reply and should retry the connection later.
Do not merge before the `Devolutions.Now.Policy.Client` and UniGetUI releases above are available.
BREAKING CHANGE: package broker clients running as standard users must open the pipe without `GENERIC_WRITE`.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This branch has not been deployed
No deployments
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
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens the default
NamedPipeBrokerTransportofDevolutions.Now.Policy.Clientand makes it configurable, so consumers no longer need their own pipe transports.Default behavior (Windows)
PipeAccessRights.Read | Synchronize | WriteData(FILE_GENERIC_READ | FILE_WRITE_DATA) instead ofGENERIC_READ | GENERIC_WRITE. This is a subset of what current and upcoming brokers grant, so the change is backward compatible. Write-attributes access is not requested because the read mode is never changed.SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION).GetNamedPipeServerProcessIdmust match the running process of theDevolutionsAgent(MSI) ordevolutions-agent(manual registration) service, reported by the SCM (QueryServiceStatusEx), and that service must be configured to run as LocalSystem (QueryServiceConfig). This works for standard users. When the caller can open the server process (elevated callers), its token user must also be LocalSystem, and the process handle is held until the exchange completes. Failures throwBrokerClientExceptionwith the newBrokerClientErrorKind.ServerVerificationFailed. On other platforms, verification fails closed unless disabled.503replies carryingRetry-Afterare retried over a new connection, at most 3 times by default, with the delay capped at 5 s.503replies withoutRetry-Afterare returned as before.GET,HEAD, evaluation, status and policy validation. Other requests, such as executions and policy replacements, fail withBrokerUnavailable. Partial responses are never retried. Busy and temporarily missing pipe instances were already retried while connecting, withinConnectTimeout.HTTP/1.1with a status code from 100 to 599;Content-Lengthis required andTransfer-Encodingis rejected;Configuration (additive API)
NamedPipeBrokerTransportOptions(record), accepted by a newNamedPipeBrokerTransport(NamedPipeBrokerTransportOptions)constructor and byBrokerClientOptions.NamedPipeTransport:PipeNameConnectTimeout,ResponseTimeoutMaxResponseHeaderBytes,MaxResponseBodyBytesImpersonationLevelVerifyServer,ServerServiceNamesServerAuthenticator,ServerAuthenticatorTimeoutMaxBusyRetries,MaxBusyRetryDelayIncompleteResponseErrorKindBrokerPipeServerAuthenticatorhook withBrokerPipeServerContext. The context exposes the connected pipe, the pipe name, the endpoint, the server process id, and the held server process handle when available. The hook runs after built-in verification and before any write, and is bounded by its own deadline. Its returnedIDisposablelives until the response is read, and late results are disposed.NamedPipeBrokerTransport.Trace.BrokerClientforwards it toBrokerClient.Trace.ClientExecutablePathstill defaults toEnvironment.ProcessPath, the real process image path.Note:
new NamedPipeBrokerTransport(null)with a literalnullis now ambiguous at compile time.new NamedPipeBrokerTransport()and passing astring?variable still compile. Binary compatibility is unaffected.Validation
Retry-Afterhandling, resend safety, and disconnect backoffBrokerClientdefaultsGENERIC_WRITEclient but accepts the transportOpenProcessis denied as expected, and the verifier accepts the matching service.dotnet format --verify-no-changespasses, anddotnet testpasses on net10.0 locally (387 tests). net9.0 is covered by CI.