Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion eng/provenance/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -952,7 +952,6 @@
"src/DesktopHelpers/ArcForges.ContentSandbox/Tests/MacProfileTests.cs",
"src/DesktopHelpers/ArcForges.ContentSandbox/Tests/OsIsolationTests.cs",
"src/DesktopHelpers/ArcForges.ContentSandbox/Tests/packages.lock.json",
"src/DesktopHelpers/ArcForges.ContentSandbox/macos/ArcForges.ContentSandbox.entitlements",
"src/BuildingBlocks/ArcForges.Security/CapabilityEnforcement/ArcForges.Security.CapabilityEnforcement.csproj",
"src/BuildingBlocks/ArcForges.Security/CapabilityEnforcement/CapabilityEvidence.cs",
"src/BuildingBlocks/ArcForges.Security/CapabilityEnforcement/CapabilityGate.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ public enum ContentSandboxProfile
/// <summary>Linux Landlock, seccomp and no_new_privs.</summary>
LinuxLandlockSeccomp = 2,

/// <summary>macOS App Sandbox with an XPC descriptor handoff. It is not implemented: a launch for it is refused.</summary>
/// <summary>
/// Reserved and wire-stable; macOS is not a supported platform. No profile of this family exists, it is never selected, and a launch or a
/// launch frame carrying it is refused fail-closed (security.isolation_unavailable).
/// </summary>
MacOsAppSandboxXpc = 3,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ var image = await invocation.OpenImageAsync(0, 0, 1);
using var tile = (await invocation.ReadImageTileAsync(image.Value, 0, 0, 256, 256)).Value!; // bytes verified on a private copy
```

- **No fallback.** A platform without a verified profile (macOS here, an unknown OS, a failed AppContainer or self-check) fails with
- **No fallback.** A platform without a verified profile (macOS, which is not a supported platform, an unknown OS, a failed AppContainer or self-check) fails with
`security.isolation_unavailable`, starts nothing, and never parses in process.
- **Windows (`Windows/`).** The helper is opened with writers refused and its SHA-256 compared with the pin; an AppContainer identity is leased from
a pool of eight (a lock file per identity, released by the OS if the owner ends); the process is created suspended with a handle list naming
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ internal enum ContentSandboxProfileKind : byte
/// <summary>Linux Landlock, seccomp and no_new_privs.</summary>
LinuxLandlockSeccomp = 2,

/// <summary>macOS App Sandbox with an XPC descriptor handoff (not implemented here; a launch for it is refused).</summary>
/// <summary>
/// Reserved and wire-stable; macOS is not a supported platform. No profile of this family exists, it is never selected, and a frame carrying
/// it is refused by the helper with the isolation-unavailable exit on every operating system.
/// </summary>
MacOsAppSandboxXpc = 3,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ namespace ArcForges.ContentSandbox.Host;
/// </summary>
internal static class ProfileEnforcement
{
/// <summary>The profile family of the operating system this process runs on, or macOS/unsupported values the launch frame never matches.</summary>
internal static ContentSandboxProfileKind ThisPlatform =>
/// <summary>
/// The profile family of the operating system this process runs on. It is null where no restricted profile exists (macOS and every other
/// operating system), so no launch frame matches it and HelperEntry refuses the frame with the isolation-unavailable exit.
/// </summary>
internal static ContentSandboxProfileKind? ThisPlatform =>
OperatingSystem.IsWindows() ? ContentSandboxProfileKind.WindowsAppContainerJob
: OperatingSystem.IsLinux() ? ContentSandboxProfileKind.LinuxLandlockSeccomp
: ContentSandboxProfileKind.MacOsAppSandboxXpc;
: null;

/// <summary>Applies and verifies the profile; false means the helper must not run a parser.</summary>
[SuppressMessage("Design", "CA1031", Justification = "Any failure of enforcement or verification is a refusal to run.")]
Expand Down
6 changes: 3 additions & 3 deletions src/DesktopHelpers/ArcForges.ContentSandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ generated `ContentSandboxService` contract. This project is non-packable; packag
filter to itself and checks them. Seccomp is synchronised to every thread (TSYNC). Landlock restricts only the calling thread unless its own
TSYNC flag is given, so the helper passes that flag, refuses to run if the kernel rejects it (a kernel without multithread Landlock, which
is recent), and its self-check observes the denial from the applying thread, from several thread-pool threads and from a new thread. Parent loss is detected by the helper polling its parent id (no parent-death signal: the kernel ties it to the spawning thread, which may retire while the parent lives) and the supervising parent kills the helper on every path. A wedged helper whose parent was killed is not ended by the kernel. No offline test observes the Landlock TSYNC flag or the shrink seal (a mutant that drops either survives offline); both need a Linux kernel. Not covered on Linux: tgkill to another same-user process (the runtime needs it), no PID/user/mount namespaces, and
the profile has never run on a Linux kernel here. macOS: no launcher exists; `macos/ArcForges.ContentSandbox.entitlements` is the declarative input of the
profile that a signed sandboxed bundle and an XPC descriptor handoff would need, and a launch there is refused.
the profile has never run on a Linux kernel here. macOS is not supported: there is no launcher, no profile and no entitlements file; the
reserved `MacOsAppSandboxXpc` value is never selected, and a launch or a frame carrying it is refused (security.isolation_unavailable).
- **Fixture (`Fixture/`).** TEST ONLY: the same host with one deliberately hostile first-party test composition (the registered
`SUB-hostile-test-parser`). It parses no real format. Its script attacks the boundary (reads a product file, connects to a real listener,
sends datagrams, opens other processes, spawns, maps the read-only input writable) or crashes, hangs, exhausts memory or overruns its
Expand Down Expand Up @@ -51,7 +51,7 @@ Publishing needs the Visual Studio C++ tools on the path of the shell; on a mach
## What is not proven

See the Plan ledger record of PLT.45. In short: the Linux profile and launcher are compiled in hosted CI and unit-tested only; they have not been run
on Linux. macOS has no launcher. Windows signature (Authenticode) verification of the helper is not implemented: the launcher pins the
on Linux. macOS is not supported and has no launcher. Windows signature (Authenticode) verification of the helper is not implemented: the launcher pins the
SHA-256 of the installed helper from the signed inventory.

## Production composition
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,9 @@ public void TheProfileOfThisPlatformIsReportedWithoutLaunchingAnything()
var status = ContentSandboxLauncher.ProbeProfile();
Assert.Equal(OperatingSystem.IsWindows(), status.Profile == ContentSandboxProfile.WindowsAppContainerJob);
Assert.Equal(OperatingSystem.IsLinux(), status.Profile == ContentSandboxProfile.LinuxLandlockSeccomp);
if (OperatingSystem.IsMacOS())
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux())
{
// macOS, and any other operating system: a typed refusal, never an available profile.
Assert.False(status.IsAvailable);
Assert.NotEmpty(status.Reason);
}
Expand Down
133 changes: 112 additions & 21 deletions src/DesktopHelpers/ArcForges.ContentSandbox/Tests/MacProfileTests.cs
Original file line number Diff line number Diff line change
@@ -1,42 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-only
using System.Xml.Linq;
using System.Diagnostics;
using System.Security.Cryptography;
using ArcForges.ContentSandbox.Broker;
using ArcForges.ContentSandbox.Contracts;
using ArcForges.ContentSandbox.Host;
using Xunit;

namespace ArcForges.ContentSandbox.Tests;

/// <summary>The macOS profile is declarative data and a refusal: no launcher exists, so nothing is ever started on that platform.</summary>
/// <summary>
/// macOS is not a supported platform, so its restricted profile is a typed refusal and nothing else. The broker launches nothing there, and a
/// helper that receives a frame carrying the reserved macOS value exits with the isolation-unavailable code before any resource or parser exists.
/// </summary>
public sealed class MacProfileTests
{
private static string EntitlementsPath()
private static CancellationToken Ct => TestContext.Current.CancellationToken;

[Fact]
public async Task ALaunchOnMacOsIsRefusedWithoutStartingAnything()
{
var options = Fixtures.Options();
await using var launcher = new ContentSandboxLauncher(options, ContentSandboxProfile.MacOsAppSandboxXpc, launcherOverride: null);
var result = await launcher.LaunchAsync("data"u8.ToArray(), Ct);
Assert.False(result.IsSuccess);
Assert.Equal("security.isolation_unavailable", result.Failure!.Code);
Assert.False(Directory.Exists(options.RuntimeRoot));
}

[Fact]
public void TheReservedMacOsValueIsWireStableAndIsNeverThisPlatformsProfile()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DesktopPlatform.slnx")))
Assert.Equal((byte)3, (byte)ContentSandboxProfileKind.MacOsAppSandboxXpc);
Assert.NotEqual((ContentSandboxProfileKind?)ContentSandboxProfileKind.MacOsAppSandboxXpc, ProfileEnforcement.ThisPlatform);
}

[Fact]
public void TheWindowsAndLinuxKindsAreUnchangedAndAreTheOnlyProfilesOfThisPlatform()
{
Assert.Equal((byte)1, (byte)ContentSandboxProfileKind.WindowsAppContainerJob);
Assert.Equal((byte)2, (byte)ContentSandboxProfileKind.LinuxLandlockSeccomp);
if (OperatingSystem.IsWindows())
{
directory = directory.Parent;
Assert.Equal<ContentSandboxProfileKind?>(ContentSandboxProfileKind.WindowsAppContainerJob, ProfileEnforcement.ThisPlatform);
}
else if (OperatingSystem.IsLinux())
{
Assert.Equal<ContentSandboxProfileKind?>(ContentSandboxProfileKind.LinuxLandlockSeccomp, ProfileEnforcement.ThisPlatform);
}
else
{
Assert.Null(ProfileEnforcement.ThisPlatform);
}

return Path.Combine(directory?.FullName ?? throw new InvalidOperationException("The repository root was not found."),
"src", "DesktopHelpers", "ArcForges.ContentSandbox", "macos", "ArcForges.ContentSandbox.entitlements");
}

[Fact]
public void TheEntitlementsEnableTheAppSandboxAndNothingElse()
public async Task AHelperRefusesAFrameCarryingTheMacOsValueWithTheIsolationUnavailableExit()
{
var document = XDocument.Load(EntitlementsPath());
var keys = document.Descendants("key").Select(key => key.Value).ToArray();
Assert.Equal(["com.apple.security.app-sandbox"], keys);
Assert.Equal("true", document.Descendants("dict").Single().Elements().ElementAt(1).Name.LocalName);
Assert.DoesNotContain(keys, key => key.Contains("network", StringComparison.Ordinal)
|| key.Contains("files", StringComparison.Ordinal) || key.Contains("inherit", StringComparison.Ordinal)
|| key.Contains("application-groups", StringComparison.Ordinal));
var exit = await RunHelperAsync(ContentSandboxProfileKind.MacOsAppSandboxXpc, Ct);
Assert.Equal(ContentSandboxContract.ExitIsolationUnavailable, exit);
}

[Fact]
public async Task ALaunchOnMacOsIsRefusedWithoutStartingAnything()
public async Task AHelperKeepsItsOwnPlatformKindPastTheProfileGate()
{
await using var launcher = new ContentSandboxLauncher(Fixtures.Options(), ContentSandboxProfile.MacOsAppSandboxXpc, launcherOverride: null);
var result = await launcher.LaunchAsync("data"u8.ToArray(), TestContext.Current.CancellationToken);
Assert.Equal("security.isolation_unavailable", result.Failure!.Code);
Assert.SkipUnless(OperatingSystem.IsWindows() || OperatingSystem.IsLinux(), "This platform has no restricted profile kind.");
var own = OperatingSystem.IsWindows() ? ContentSandboxProfileKind.WindowsAppContainerJob : ContentSandboxProfileKind.LinuxLandlockSeccomp;

// The bootstrap resource below is too short to be a launch descriptor, so the helper stops at that check, after the profile gate.
var exit = await RunHelperAsync(own, Ct);
Assert.Equal(ContentSandboxContract.ExitLaunchFrameInvalid, exit);
}

private static string HelperPath() =>
Path.Combine(AppContext.BaseDirectory, "ArcForges.ContentSandbox" + (OperatingSystem.IsWindows() ? ".exe" : string.Empty));

private static ContentSandboxLaunchFrame Frame(ContentSandboxProfileKind kind) =>
new(
kind,
Guid.NewGuid(),
Guid.NewGuid(),
1,
Guid.NewGuid(),
100,
SHA256.HashData("input"u8),
[1024],
new ContentSandboxLimits(),
[
new(ContentSandboxHandleRole.Control, 10),
new(ContentSandboxHandleRole.Service, 11),
new(ContentSandboxHandleRole.Input, 12),
new(ContentSandboxHandleRole.Slot0, 13),
],
"hostile-test-parser",
[1, 2, 3, 4]);

/// <summary>Starts the built helper, writes one launch frame of the given kind to its standard input and returns its exit code.</summary>
private static async Task<int> RunHelperAsync(ContentSandboxProfileKind kind, CancellationToken cancellationToken)
{
var helper = HelperPath();
Assert.True(File.Exists(helper), "The helper host was not built next to the tests: " + helper);
using var frame = Frame(kind);
var encoded = frame.Encode();
var startInfo = new ProcessStartInfo(helper)
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("The helper host did not start.");
var output = process.StandardOutput.ReadToEndAsync(cancellationToken);
var diagnostics = process.StandardError.ReadToEndAsync(cancellationToken);
await process.StandardInput.BaseStream.WriteAsync(encoded, cancellationToken);
await process.StandardInput.BaseStream.FlushAsync(cancellationToken);
process.StandardInput.Close();
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(60));
try
{
await process.WaitForExitAsync(deadline.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw;
}

_ = await output;
_ = await diagnostics;
return process.ExitCode;
}
}

This file was deleted.

Loading