From 8c8a66ddfc0233e0e8e6cabbf9e2d9c6fed4489d Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Fri, 14 Aug 2026 17:37:48 -0700 Subject: [PATCH 1/4] Guard the proxy key contract against hand-written expectations `cooperative_env_scrubs_all_caller_supplied_proxy_keys` asserted FTP_PROXY was absent while a sibling test asserted every PROXY_SET_KEYS entry was present at the proxy URL. #923 corrected the assertion; nothing yet stops the next hand-written per-key expectation from drifting the same way. `every_managed_key_lands_in_the_state_its_constants_dictate` derives the expected state of all ten managed keys from PROXY_SET_KEYS and PROXY_NEUTRALIZE_KEYS rather than naming keys by hand, under hostile caller input for every key. No existing test covered that combination: the scrub test supplies hostile values but checks three keys, and the set test checks every key but starts from an empty caller environment. Extends the WSLc proxy e2e to the FTP family for the same reason. The assertion is case-sensitive because PowerShell `-notmatch` is not, which would let the upper-case entry satisfy both checks and leave a lower-case attacker value untested. Refs #922 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: acf3853b-fbe3-41ab-870a-9274a698cc0c --- src/core/wxc_common/tests/proxy_env_spec.rs | 30 +++++++++++++++++++++ tests/configs/wslc_network_proxy.json | 4 +-- tests/scripts/run_wslc_proxy_test.ps1 | 9 +++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/core/wxc_common/tests/proxy_env_spec.rs b/src/core/wxc_common/tests/proxy_env_spec.rs index 238f0db52..b68ddf96a 100644 --- a/src/core/wxc_common/tests/proxy_env_spec.rs +++ b/src/core/wxc_common/tests/proxy_env_spec.rs @@ -194,6 +194,36 @@ fn cooperative_env_scrubs_all_caller_supplied_proxy_keys() { assert_eq!(value_for(&result, "ftp_proxy"), Some(PROXY_URL)); } +// Protects clients (b), (c), and (d): the three key-set constants are the +// contract, so this derives the expected state of every managed key from them +// instead of naming keys by hand. A hand-written per-key expectation can +// silently disagree with the constant that actually drives the behavior -- +// which is how an assertion that FTP_PROXY stays absent survived FTP_PROXY +// being added to PROXY_SET_KEYS, leaving the suite asserting both at once. +#[test] +fn every_managed_key_lands_in_the_state_its_constants_dictate() { + let hostile = "http://attacker.example:9999"; + let caller: Vec = PROXY_ENV_KEYS + .iter() + .map(|key| format!("{key}={hostile}")) + .collect(); + + let result = apply_cooperative_proxy_env(&caller, PROXY_URL); + + for key in PROXY_ENV_KEYS { + let expected = if PROXY_SET_KEYS.contains(key) { + Some(PROXY_URL) + } else if PROXY_NEUTRALIZE_KEYS.contains(key) { + Some("") + } else { + None + }; + assert_eq!(value_for(&result, key), expected, "managed key {key}"); + } + + assert!(!result.iter().any(|entry| entry.contains(hostile))); +} + // Protects client (d): duplicate and mixed-case proxy keys are an obvious // evasion attempt. Neither the second copy nor an unusual casing survives with // the workload's value. diff --git a/tests/configs/wslc_network_proxy.json b/tests/configs/wslc_network_proxy.json index 8556b438e..a96793909 100644 --- a/tests/configs/wslc_network_proxy.json +++ b/tests/configs/wslc_network_proxy.json @@ -3,8 +3,8 @@ "containerId": "wslc-network-proxy", "containment": "wslc", "process": { - "commandLine": "set -e; ( while true; do printf 'HTTP/1.0 200 OK\\r\\nContent-Length: 9\\r\\nConnection: close\\r\\n\\r\\nPROXY_HIT' | nc -l -p 8888 -w 2 >/dev/null 2>&1; done ) & sleep 1; echo \"PROXY_ENV HTTP_PROXY=[$HTTP_PROXY] http_proxy=[$http_proxy] NO_PROXY=[$NO_PROXY] no_proxy=[$no_proxy]\"; BODY=$(wget -q -O - http://example.com/ 2>/dev/null || true); echo \"CLIENT_GOT=[$BODY]\"; case \"$BODY\" in *PROXY_HIT*) echo WSLC_PROXY_FUNCTIONAL_OK ;; *) echo WSLC_PROXY_FAIL ;; esac", - "env": ["HTTP_PROXY=http://attacker.invalid:1", "http_proxy=http://attacker.invalid:1", "NO_PROXY=*", "no_proxy=attacker.invalid"] + "commandLine": "set -e; ( while true; do printf 'HTTP/1.0 200 OK\\r\\nContent-Length: 9\\r\\nConnection: close\\r\\n\\r\\nPROXY_HIT' | nc -l -p 8888 -w 2 >/dev/null 2>&1; done ) & sleep 1; echo \"PROXY_ENV HTTP_PROXY=[$HTTP_PROXY] http_proxy=[$http_proxy] FTP_PROXY=[$FTP_PROXY] ftp_proxy=[$ftp_proxy] NO_PROXY=[$NO_PROXY] no_proxy=[$no_proxy]\"; BODY=$(wget -q -O - http://example.com/ 2>/dev/null || true); echo \"CLIENT_GOT=[$BODY]\"; case \"$BODY\" in *PROXY_HIT*) echo WSLC_PROXY_FUNCTIONAL_OK ;; *) echo WSLC_PROXY_FAIL ;; esac", + "env": ["HTTP_PROXY=http://attacker.invalid:1", "http_proxy=http://attacker.invalid:1", "FTP_PROXY=http://attacker.invalid:1", "ftp_proxy=http://attacker.invalid:1", "NO_PROXY=*", "no_proxy=attacker.invalid"] }, "network": { "defaultPolicy": "allow", diff --git a/tests/scripts/run_wslc_proxy_test.ps1 b/tests/scripts/run_wslc_proxy_test.ps1 index d8f6ff302..9da6300c0 100644 --- a/tests/scripts/run_wslc_proxy_test.ps1 +++ b/tests/scripts/run_wslc_proxy_test.ps1 @@ -96,6 +96,15 @@ if ($pass -and ($childLine -match 'attacker\.invalid')) { $reason = "caller-supplied proxy env var leaked to the child (attacker.invalid not scrubbed)" } +# The FTP family is set to the proxy as well, so an image-baked ftp_proxy +# cannot send FTP traffic somewhere the operator did not choose. Matched +# case-sensitively: -notmatch would let the upper-case entry satisfy both +# checks and leave the lower-case one untested. +if ($pass -and (($childLine -cnotmatch 'FTP_PROXY=\[http://127\.0\.0\.1:8888\]') -or ($childLine -cnotmatch 'ftp_proxy=\[http://127\.0\.0\.1:8888\]'))) { + $pass = $false + $reason = "runner did not inject/scrub the FTP proxy family (expected http://127.0.0.1:8888)" +} + # NO_PROXY / no_proxy must be neutralized to empty so an image-baked or # caller-supplied exemption (e.g. NO_PROXY=*) cannot disable the proxy. if ($pass -and (($childLine -notmatch 'NO_PROXY=\[\]') -or ($childLine -notmatch 'no_proxy=\[\]'))) { From 20ac74b47db96fe2672b059b8744b71895008f56 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Fri, 14 Aug 2026 19:19:05 -0700 Subject: [PATCH 2/4] Seed the readonly fixture the WSLc e2e reads but never created wslc_readonly_mount.json cats /mnt/c/workspace/test.txt, and nothing on the WSLc path ever wrote that file, so the test failed on a missing fixture rather than on a mount defect -- the write half was correctly blocked. The LXC and bubblewrap suites already seed the same fixture before their equivalent test (run_lxc_filesystem_test.sh:21, run_bwrap_filesystem_test.sh:25). WSLc was the odd one out. Full suite now 23/23 passed with 2 documented tar skips, and the state-aware suite 57/57, on WSL 2.9.4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: acf3853b-fbe3-41ab-870a-9274a698cc0c --- tests/scripts/run_wslc_all_tests.ps1 | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index 9c2a6cb49..c1a127f8a 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -215,6 +215,19 @@ $null = $results.Add((Run-WslcTest "wslc_stderr.json" -OutputContains "stdout me $null = $results.Add((Run-WslcTest "wslc_large_output.json")) Write-Host "`n--- Filesystem Tests ---" -ForegroundColor Cyan + +# Seed the readonly fixture the same way the LXC and bubblewrap suites do +# (run_lxc_filesystem_test.sh:21, run_bwrap_filesystem_test.sh:25). Without +# this, wslc_readonly_mount.json cats a file that was never created and fails +# for a missing fixture rather than for a mount defect. +$readonlyFixtureDir = "C:\workspace" +$readonlyFixture = Join-Path $readonlyFixtureDir "test.txt" +if (-not (Test-Path $readonlyFixtureDir)) +{ + $null = New-Item -ItemType Directory -Path $readonlyFixtureDir -Force +} +Set-Content -Path $readonlyFixture -Value "test content" -Encoding ascii + # wslc_filesystem.json also asserts cpuCount + memoryMb enforcement via nproc and /proc/meminfo. $null = $results.Add((Run-WslcTest "wslc_filesystem.json" ` -OutputMatches "(?s)PASS: filesystem mount visible.*PASS: cpuCount enforced.*PASS: memoryMb enforced")) From 4606b6f514611b9472a31901f583152976445dec Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Tue, 18 Aug 2026 11:53:17 -0700 Subject: [PATCH 3/4] Move the WSLc readonly fixture to a test-only root The suite seeded C:\workspace\test.txt, which is the directory this script's own prerequisites tell developers to fill with alpine.tar, so a run overwrote a real file and left it overwritten. The fixture now lives in C:\wslcro, created and removed by the harness in try/finally the way the other WSLc fixture scripts already do (run_wslc_denied_masking_test.ps1:56-61,107-108, run_wslc_object_test.ps1:53-58,87-88). Cleanup also removes the probe file a wrongly-writable mount would leave behind. wslc_readonly_mount.json moves with it: readonlyPaths and both guest paths in commandLine, which windows_path_to_container_path derives as /mnt/c/wslcro (policy_mapping.rs:39-65). Dropping the fixture from C:\workspace also drops the New-Item that created it, whose only job was to host test.txt. wslc_filesystem.json still mounts C:\workspace and again relies on the developer providing it, as it does on main and as the header prerequisites state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ffc3254-b392-4298-a0f9-dbdf1d36fa55 --- tests/configs/wslc_readonly_mount.json | 4 +-- tests/scripts/run_wslc_all_tests.ps1 | 34 +++++++++++++++++--------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/tests/configs/wslc_readonly_mount.json b/tests/configs/wslc_readonly_mount.json index 6a069d0ac..5ad6d429e 100644 --- a/tests/configs/wslc_readonly_mount.json +++ b/tests/configs/wslc_readonly_mount.json @@ -3,10 +3,10 @@ "containerId": "wslc-readonly-mount", "containment": "wslc", "process": { - "commandLine": "cat /mnt/c/workspace/test.txt && echo 'Read succeeded' && echo 'write test' > /mnt/c/workspace/readonly_write_test.txt && echo 'Write succeeded (unexpected)' || echo 'Write blocked (expected for readonly)'" + "commandLine": "cat /mnt/c/wslcro/test.txt && echo 'Read succeeded' && echo 'write test' > /mnt/c/wslcro/readonly_write_test.txt && echo 'Write succeeded (unexpected)' || echo 'Write blocked (expected for readonly)'" }, "filesystem": { - "readonlyPaths": ["C:\\workspace"] + "readonlyPaths": ["C:\\wslcro"] }, "network": { "defaultPolicy": "block" diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index c1a127f8a..bb64596e0 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -216,22 +216,34 @@ $null = $results.Add((Run-WslcTest "wslc_large_output.json")) Write-Host "`n--- Filesystem Tests ---" -ForegroundColor Cyan +# wslc_filesystem.json also asserts cpuCount + memoryMb enforcement via nproc and /proc/meminfo. +$null = $results.Add((Run-WslcTest "wslc_filesystem.json" ` + -OutputMatches "(?s)PASS: filesystem mount visible.*PASS: cpuCount enforced.*PASS: memoryMb enforced")) + +# Fixed paths must match tests\configs\wslc_readonly_mount.json. +$readonlyFixtureDir = "C:\wslcro" +$readonlyFixture = Join-Path $readonlyFixtureDir "test.txt" + +function Remove-ReadonlyFixture { + Remove-Item -Recurse -Force $readonlyFixtureDir -ErrorAction SilentlyContinue +} + # Seed the readonly fixture the same way the LXC and bubblewrap suites do # (run_lxc_filesystem_test.sh:21, run_bwrap_filesystem_test.sh:25). Without # this, wslc_readonly_mount.json cats a file that was never created and fails -# for a missing fixture rather than for a mount defect. -$readonlyFixtureDir = "C:\workspace" -$readonlyFixture = Join-Path $readonlyFixtureDir "test.txt" -if (-not (Test-Path $readonlyFixtureDir)) -{ +# for a missing fixture rather than for a mount defect. The root is test-only +# and removed in the finally, so a developer's C:\workspace -- which the +# prerequisites above tell them to fill with alpine.tar -- is never written to, +# and a mount that is wrongly writable does not leave its probe file behind. +Remove-ReadonlyFixture +try { $null = New-Item -ItemType Directory -Path $readonlyFixtureDir -Force -} -Set-Content -Path $readonlyFixture -Value "test content" -Encoding ascii + Set-Content -Path $readonlyFixture -Value "test content" -Encoding ascii -# wslc_filesystem.json also asserts cpuCount + memoryMb enforcement via nproc and /proc/meminfo. -$null = $results.Add((Run-WslcTest "wslc_filesystem.json" ` - -OutputMatches "(?s)PASS: filesystem mount visible.*PASS: cpuCount enforced.*PASS: memoryMb enforced")) -$null = $results.Add((Run-WslcTest "wslc_readonly_mount.json" -OutputContains "Read succeeded")) + $null = $results.Add((Run-WslcTest "wslc_readonly_mount.json" -OutputContains "Read succeeded")) +} finally { + Remove-ReadonlyFixture +} Write-Host "`n--- Object Validation Tests ---" -ForegroundColor Cyan # Object-based validation (roadmap D6): a directory under readwritePaths and a From f5301bbdd2af4b0dd08491f98edd7f4810bb4341 Mon Sep 17 00:00:00 2001 From: Darren Hoehna Date: Tue, 18 Aug 2026 12:01:07 -0700 Subject: [PATCH 4/4] Give the WSLc filesystem test a harness-owned mount root wslc_filesystem.json mounted C:\workspace read-write and used it as cwd, and nothing in the repo ever creates that directory, so the test passed only on a machine where the developer had already made it by hand for the tar prerequisites. On a clean machine it failed with "FAIL: /mnt/c/workspace not visible". It now mounts C:\wslcfs, which the harness creates and removes in the same try/finally as the readonly fixture, so the suite supplies its own mount targets. Verified from a clean state with neither root present: wslc_filesystem.json printed all three PASS lines and exited 0, wslc_readonly_mount.json read its fixture and had its write probe refused with "Read-only file system", and both roots were gone afterwards. The tar imports still read C:\workspace\alpine.tar, which is a genuine developer prerequisite the harness cannot produce, and those tests already skip when it is absent (run_wslc_all_tests.ps1:117-124). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ffc3254-b392-4298-a0f9-dbdf1d36fa55 --- tests/configs/wslc_filesystem.json | 6 ++--- tests/scripts/run_wslc_all_tests.ps1 | 34 ++++++++++++++++------------ 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/tests/configs/wslc_filesystem.json b/tests/configs/wslc_filesystem.json index d9c63abc7..d655fba6a 100644 --- a/tests/configs/wslc_filesystem.json +++ b/tests/configs/wslc_filesystem.json @@ -3,11 +3,11 @@ "containerId": "wslc-filesystem-test", "containment": "wslc", "process": { - "commandLine": "set -e; ls -la /mnt/c/workspace >/dev/null && echo 'PASS: filesystem mount visible' || { echo 'FAIL: /mnt/c/workspace not visible'; exit 1; }; cpus=$(nproc); echo \"CPU_COUNT=$cpus\"; [ \"$cpus\" = \"2\" ] && echo 'PASS: cpuCount enforced (nproc=2)' || { echo \"FAIL: expected nproc=2, got $cpus\"; exit 1; }; mem_kb=$(awk '/MemTotal:/ {print $2}' /proc/meminfo); mem_mb=$((mem_kb/1024)); echo \"MEM_TOTAL_MB=$mem_mb\"; [ $mem_mb -ge 820 ] && [ $mem_mb -le 1075 ] && echo 'PASS: memoryMb enforced (~1024MB, kernel-visible 80-105%)' || { echo \"FAIL: expected 820-1075MB, got ${mem_mb}MB\"; exit 1; }", - "cwd": "C:\\workspace" + "commandLine": "set -e; ls -la /mnt/c/wslcfs >/dev/null && echo 'PASS: filesystem mount visible' || { echo 'FAIL: /mnt/c/wslcfs not visible'; exit 1; }; cpus=$(nproc); echo \"CPU_COUNT=$cpus\"; [ \"$cpus\" = \"2\" ] && echo 'PASS: cpuCount enforced (nproc=2)' || { echo \"FAIL: expected nproc=2, got $cpus\"; exit 1; }; mem_kb=$(awk '/MemTotal:/ {print $2}' /proc/meminfo); mem_mb=$((mem_kb/1024)); echo \"MEM_TOTAL_MB=$mem_mb\"; [ $mem_mb -ge 820 ] && [ $mem_mb -le 1075 ] && echo 'PASS: memoryMb enforced (~1024MB, kernel-visible 80-105%)' || { echo \"FAIL: expected 820-1075MB, got ${mem_mb}MB\"; exit 1; }", + "cwd": "C:\\wslcfs" }, "filesystem": { - "readwritePaths": ["C:\\workspace"] + "readwritePaths": ["C:\\wslcfs"] }, "network": { "defaultPolicy": "allow" diff --git a/tests/scripts/run_wslc_all_tests.ps1 b/tests/scripts/run_wslc_all_tests.ps1 index bb64596e0..4e4958f50 100644 --- a/tests/scripts/run_wslc_all_tests.ps1 +++ b/tests/scripts/run_wslc_all_tests.ps1 @@ -216,33 +216,37 @@ $null = $results.Add((Run-WslcTest "wslc_large_output.json")) Write-Host "`n--- Filesystem Tests ---" -ForegroundColor Cyan -# wslc_filesystem.json also asserts cpuCount + memoryMb enforcement via nproc and /proc/meminfo. -$null = $results.Add((Run-WslcTest "wslc_filesystem.json" ` - -OutputMatches "(?s)PASS: filesystem mount visible.*PASS: cpuCount enforced.*PASS: memoryMb enforced")) - -# Fixed paths must match tests\configs\wslc_readonly_mount.json. +# Fixed paths must match tests\configs\wslc_filesystem.json and +# tests\configs\wslc_readonly_mount.json. +$fsFixtureDir = "C:\wslcfs" $readonlyFixtureDir = "C:\wslcro" $readonlyFixture = Join-Path $readonlyFixtureDir "test.txt" -function Remove-ReadonlyFixture { +function Remove-FilesystemFixtures { + Remove-Item -Recurse -Force $fsFixtureDir -ErrorAction SilentlyContinue Remove-Item -Recurse -Force $readonlyFixtureDir -ErrorAction SilentlyContinue } -# Seed the readonly fixture the same way the LXC and bubblewrap suites do -# (run_lxc_filesystem_test.sh:21, run_bwrap_filesystem_test.sh:25). Without -# this, wslc_readonly_mount.json cats a file that was never created and fails -# for a missing fixture rather than for a mount defect. The root is test-only -# and removed in the finally, so a developer's C:\workspace -- which the -# prerequisites above tell them to fill with alpine.tar -- is never written to, -# and a mount that is wrongly writable does not leave its probe file behind. -Remove-ReadonlyFixture +# Both filesystem configs mount a host directory the suite owns outright, so a +# run needs nothing hand-made and never touches C:\workspace, where the +# prerequisites above tell the developer to keep alpine.tar. wslc_filesystem +# only needs its mount target to exist; wslc_readonly_mount also reads +# test.txt, seeded the way the LXC and bubblewrap suites seed theirs +# (run_lxc_filesystem_test.sh:21, run_bwrap_filesystem_test.sh:25). The +# finally removes both roots, including the probe file a wrongly-writable +# mount would leave behind. +Remove-FilesystemFixtures try { + $null = New-Item -ItemType Directory -Path $fsFixtureDir -Force $null = New-Item -ItemType Directory -Path $readonlyFixtureDir -Force Set-Content -Path $readonlyFixture -Value "test content" -Encoding ascii + # wslc_filesystem.json also asserts cpuCount + memoryMb enforcement via nproc and /proc/meminfo. + $null = $results.Add((Run-WslcTest "wslc_filesystem.json" ` + -OutputMatches "(?s)PASS: filesystem mount visible.*PASS: cpuCount enforced.*PASS: memoryMb enforced")) $null = $results.Add((Run-WslcTest "wslc_readonly_mount.json" -OutputContains "Read succeeded")) } finally { - Remove-ReadonlyFixture + Remove-FilesystemFixtures } Write-Host "`n--- Object Validation Tests ---" -ForegroundColor Cyan