Skip to content

Authorize AppContainer test hosts on the MTP controller pipe - #10491

Open
Amaury Levé (Evangelink) wants to merge 3 commits into
dev/amauryleve/deliver-appcontainer-activation-argsfrom
dev/amauryleve/authorize-appcontainer-on-mtp-controller
Open

Authorize AppContainer test hosts on the MTP controller pipe#10491
Amaury Levé (Evangelink) wants to merge 3 commits into
dev/amauryleve/deliver-appcontainer-activation-argsfrom
dev/amauryleve/authorize-appcontainer-on-mtp-controller

Conversation

@Evangelink

@Evangelink Amaury Levé (Evangelink) commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #10486.

Related: #10485 (companion issue: delivering MTP activation arguments to AppContainer test hosts), implemented by #10489.

Stacked on #10489. This PR targets dev/amauryleve/deliver-appcontainer-activation-args so it can consume that branch's finalized manifest contract rather than duplicate it. The 28 files below are all pipe authorization. Retarget to main once #10489 merges — the Closes #10486 keyword only takes effect once the base is the default branch.

Problem

MTP creates the test-host-controller named pipe with PipeOptions.CurrentUserOnly. On Windows that means a security descriptor whose owner is the creating token's owner SID and whose DACL contains a single ACE granting that same SID full control — which is what gives the pipe its current-user and elevation protection.

A Windows AppContainer host runs with a restricted token. Windows grants access only when the normal access check and the restricted-SID check both succeed, and an AppContainer's restricting SIDs contain the package SID. A DACL that names only the user therefore denies the host even though it belongs to the same signed-in user — knowing the pipe name, or restoring the activation arguments from #10489, changes nothing.

Ordering problem

The pipe has to be listening before the host is launched, so it is created in TestHostControllersTestHost.InternalRunAsync well before ITestHostLauncher.LaunchTestHostAsync runs. A launcher cannot contribute the package identity from the launch call.

The platform therefore queries a new opt-in interface immediately before creating the pipe:

[Experimental("TPEXP", ...)]
public interface ITestHostControllerPipeAuthorizer
{
    Task<IReadOnlyList<string>> GetAuthorizedAppContainerSecurityIdentifiersAsync(CancellationToken cancellationToken);
}

Alignment with #10489

The launcher decides whether to ask for the grant by reading AppxApplicationInfo.IsAppContainer — the same manifest classification #10489 uses to decide whether the host is activated with an activation payload or with plain argv. There is exactly one AppContainer classification in the extension, and this PR adds none of its own.

Because the pipe is created before the platform names the executable it wants, the specific application cannot be resolved yet. That does not weaken anything: the SID is the package's, shared by every application it declares, so "this package declares an AppContainer application" is the right predicate for authorizing it.

Security model

NamedPipeServerSecurity composes an explicit SDDL descriptor and creates the pipe through CreateNamedPipeW:

Principal Rights
The creating token's owner SID (owner and group of the pipe) PipeAccessRights.FullControl (0x1f019f) — byte-for-byte what PipeOptions.CurrentUserOnly grants
Each authorized AppContainer package SID ReadWrite | Synchronize (0x12019b) only
  • DACL is protected (D:P); the pipe also sets PIPE_REJECT_REMOTE_CLIENTS.
  • The package mask excludes FILE_CREATE_PIPE_INSTANCE, DELETE, WRITE_DAC and WRITE_OWNER, so an authorized package can talk to the controller but can never create a second instance of the pipe and impersonate it. READ_CONTROL is granted, because the client-side PipeOptions.CurrentUserOnly validation reads the owner.
  • Only a specific AppContainer SID may ever be authorized. The platform re-validates whatever the extension returns and rejects users, groups, Everyone, and in particular ALL APPLICATION PACKAGES (S-1-15-2-1) and ALL RESTRICTED APPLICATION PACKAGES (S-1-15-2-2), failing the run with a localized, actionable error rather than silently widening the pipe.
  • No mandatory integrity label is lowered, so Mandatory Integrity Control stays a second gate behind the DACL. This was investigated and measured, not assumed — see below. A test pins the absence of the label.
  • No loopback exemption is used — CheckNetIsolation LoopbackExempt applies to network sockets, not named-pipe DACLs.
  • A packaged full-trust host, an unpackaged app, an ordinary console test app, and every non-Windows run keep the existing pipe unchanged. TESTINGPLATFORM_PACKAGEDAPP_PIPEAUTHORIZATION (auto / always / never) is the escape hatch.

Three findings worth reviewing

1. Not DeriveAppContainerSidFromAppContainerName. That Win32 API looks like the obvious choice, but its result depends on the calling process: from a process that itself has package identity it returns a child AppContainer SID (the caller's package SID plus four extra sub-authorities) instead of the package SID of the requested name. That is precisely our situation — the controller is the packaged test app. Verified: derivation for microsoft.windowsnotepad_8wekyb3d8bbwe from packaged PowerShell returned S-1-15-2-<pwsh package SID>-…, not the SID Windows registered. The package SID is instead computed with the stable derivation Windows uses for its own AppContainer\Mappings table (S-1-15-2- + the first seven little-endian uint32 of SHA-256 over the lower-cased package family name in UTF-16LE), and a test cross-checks it against every package mapping registered on the machine.

2. Integrity levels. Measured against live AppContainer tokens: a low-integrity AppContainer (S-1-16-4096 — what an ordinary UWP/WinUI app runs at) connects through the package ACE with no mandatory label on the pipe, while an untrusted-integrity one (S-1-16-0 — Chromium's hardened renderers) is denied by Mandatory Integrity Control before the DACL is consulted, and no DACL change can admit it. So lowering the pipe's label would buy nothing for a real test host while removing a second gate; the pipe keeps the controller's own label.

3. Not PipeSecurity/NamedPipeServerStreamAcl. The managed ACL types are not in the netstandard2.0 surface this assembly also builds for, and PipeOptions.CurrentUserOnly cannot be combined with an explicit descriptor. CreateNamedPipeW + SDDL keeps one code path across all TFMs with no new package references.

Allowed/denied connection coverage

NamedPipeServerSecurityTests (41 tests, green on net9.0 and net462):

  • A real AppContainer connects only when its own package is authorized. A live AppContainer process's token is duplicated and impersonated, then used to open three pipes: one authorizing its package (connects), one authorizing a different package (denied), one authorizing nothing (denied). This is a genuine restricted-token access check, not a synthetic stand-in. Candidates are filtered to exclude untrusted-integrity containers per finding 2, and the test self-skips if the machine exposes no usable AppContainer; it was run repeatedly on both TFMs to confirm stability.
  • The allowed/denied identity matrix evaluated by Windows itself through the Authz API against the produced descriptor: the authorized package is granted; a different package, ALL APPLICATION PACKAGES, Everyone, Authenticated Users, another account and another user are all denied; the creating identity is granted full control.
  • The live kernel object: the security descriptor is read back from the created pipe handle with GetSecurityInfo (including LABEL_SECURITY_INFORMATION) and asserted to contain exactly the owner ACE and the one package ACE, a protected DACL, and no mandatory label.
  • A real denial through the managed client: while impersonating the anonymous token, connecting throws UnauthorizedAccessException.
  • The current user can still connect, and a full NamedPipeServer request/reply round-trip completes over the hardened (handle-created) pipe — it is created from a raw handle rather than by the NamedPipeServerStream name constructor, so the whole cycle needs its own coverage.
  • SDDL composition and SID normalization are asserted exact-string; the least-privilege policy accepts a package SID and a child AppContainer SID and rejects null/empty/malformed, both catch-all package SIDs, Everyone, Authenticated Users, LocalSystem, BUILTIN\Users, a user SID, a capability SID, a too-short SID, a non-numeric sub-authority and the AC alias. NamedPipeServer throws when handed a non-AppContainer SID (defense in depth behind the caller-side validation).

AppContainerSecurityIdentifierTests and PackagedAppPipeAuthorizationTests cover the SID derivation (including the cross-check against Windows' own AppContainer\Mappings) and every authorization decision: an AppContainer manifest yields the package SID; runFullTrust, a full-trust entry point, a loose layout, a malformed manifest, never, an unrecognized mode and non-Windows all yield nothing.

Full suites pass: Microsoft.Testing.Platform.UnitTests 2163/2163 (net9.0) and 2115/2115 (net462), Microsoft.Testing.Extensions.UnitTests 992 passed / 7 skipped. Microsoft.Testing.Platform.slnf builds with 0 warnings.

Remaining work after this PR

Nothing in either issue is left open in code. The one deliverable deliberately deferred is the combined end-to-end acceptance asset, which cannot exist until both PRs land because it needs argument delivery (#10485/#10489) and pipe authorization (this PR) in the same build:

  • register a UWP/AppContainer test layout, activate it by AUMID, have the host restore its arguments through PackagedAppExtensions.GetTestApplicationArguments, connect back on the controller pipe, and complete a run;
  • plus the negative half: a host from a different package is refused by the pipe.

Until then the boundary this PR owns is covered by real OS access checks rather than a live end-to-end run — including a duplicated, impersonated AppContainer token that connects only to the pipe authorizing its own package (see above).

Docs

docs/RFCs/017-TestHost-Launcher.md gains an "Authorizing an AppContainer on the controller pipe" section, docs/winui-testing.md gains a user-facing "Controller pipe access for AppContainer hosts" section, and the PackagedApp PACKAGE.md documents the grant and the escape hatch. The stale "#10486 is the remaining dependency" notes left by #10489 are updated in the same places.

Copilot AI balanced review requested due to automatic review settings August 6, 2026 17:26
Copilot AI added 3 commits August 6, 2026 19:33
The test host controller pipe is created with the equivalent of
PipeOptions.CurrentUserOnly: owned by the creating token's owner SID with a
DACL that grants only that SID. A Windows AppContainer host (true UWP, or a
WinUI host configured for AppContainer) runs with a restricted token, and
Windows grants access only when the normal access check and the restricted-SID
check both succeed, so such a DACL denies the host even though it belongs to
the same signed-in user.

Add a Windows-only, least-privilege authorization path:

- NamedPipeServerSecurity composes an explicit SDDL descriptor (owner keeps
  PipeAccessRights.FullControl, each authorized AppContainer package SID gets
  ReadWrite|Synchronize only, DACL protected) and creates the pipe through
  CreateNamedPipeW, additionally rejecting remote clients. The package mask
  excludes FILE_CREATE_PIPE_INSTANCE, DELETE, WRITE_DAC and WRITE_OWNER.
- Only specific AppContainer SIDs may be authorized; ALL APPLICATION PACKAGES,
  ALL RESTRICTED APPLICATION PACKAGES, users, groups and Everyone are rejected
  and fail the run instead of widening the pipe.
- A new experimental ITestHostControllerPipeAuthorizer lets a launcher
  contribute the package identity before the pipe is created, which is the only
  point where it can still be composed.
- Microsoft.Testing.Extensions.PackagedApp derives its own package SID and
  requests it only when the manifest says the app runs in an AppContainer,
  with a TESTINGPLATFORM_PACKAGEDAPP_PIPEAUTHORIZATION escape hatch.

Runs that ask for nothing, and every non-Windows run, keep the existing pipe
byte-for-byte.

Contributes to #10486. End-to-end AppContainer runs additionally need #10485.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add an end-to-end NamedPipeServer request/reply test over the hardened pipe:
it is created from a raw handle rather than by the NamedPipeServerStream name
constructor, so the whole connect/request/reply/dispose cycle needs its own
coverage.

Also read the security descriptor back with LABEL_SECURITY_INFORMATION and
assert no mandatory integrity label is ever emitted. Lowering the label was
investigated and rejected: a lowbox (AppContainer) token's access check is
satisfied by the package-SID ACE, so the label would buy nothing while
removing Mandatory Integrity Control as a second gate behind the DACL.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Rebase onto the activation-argument branch and consume its finalized manifest
contract instead of duplicating it: the launcher now asks
AppxApplicationInfo.IsAppContainer whether the package declares an
AppContainer application, which is the same classification that decides
whether the host is activated with an activation payload or with plain argv.
The AppxManifestInfo/AppxApplicationInfo additions this branch previously
carried are dropped.

Add the acceptance-level coverage this issue asks for: a live AppContainer
process's token is duplicated and impersonated, and used to open three pipes
— one authorizing its package, one authorizing a different package, and one
authorizing none. Only the first succeeds, which exercises the real
restricted-token access check rather than a synthetic stand-in.

Candidates are filtered to the shape a real test host has. Measured on
Windows: a low-integrity AppContainer (S-1-16-4096, what an ordinary
UWP/WinUI app runs at) connects through the package ACE with no mandatory
label on the pipe, while an untrusted-integrity one (S-1-16-0, Chromium's
hardened renderers) is denied by Mandatory Integrity Control before the DACL
is consulted. Selecting an arbitrary AppContainer therefore made the
assertion depend on which processes happened to be running.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Evangelink
Amaury Levé (Evangelink) force-pushed the dev/amauryleve/authorize-appcontainer-on-mtp-controller branch from aead3b9 to c797a7f Compare August 6, 2026 17:49
@Evangelink
Amaury Levé (Evangelink) changed the base branch from main to dev/amauryleve/deliver-appcontainer-activation-args August 6, 2026 17:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Note

This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants