From 43f7112086622105dd4d6128d34d5305478d670b Mon Sep 17 00:00:00 2001 From: Srikrishna Veturi Date: Mon, 4 May 2026 13:33:49 -0500 Subject: [PATCH 1/5] Add test scenario coverage for Linux distros (#337) * Add test scenario coverage for Linux distros * Delete Mariner tests and Fix Suse tests * Add Zypper to spell checker * Install jq in Suse --------- Co-authored-by: Srikrishna Veturi --- .github/actions/spelling/expect.txt | 3 ++- .github/workflows/codeql.yml | 2 +- .github/workflows/reusable-build.yml | 2 +- .../GuestProxyAgentExtensionValidation.sh | 22 +++++++++++++++++++ .../TestMap/Mariner2-Fips-TestGroup.yml | 12 ---------- .../TestMap/Redhat90-Arm64-TestGroup.yml | 6 ++++- .../TestMap/Redhat90-TestGroup.yml | 6 ++++- .../TestMap/Rocky9-TestGroup.yml | 6 ++++- .../TestMap/Suse15SP4-Arm64-TestGroup.yml | 6 ++++- .../TestMap/Suse15SP4-TestGroup.yml | 6 ++++- .../TestMap/Ubuntu20-TestGroup.yml | 6 ++++- .../TestMap/Ubuntu22-Arm64-TestGroup.yml | 6 ++++- .../TestMap/Ubuntu22-TestGroup.yml | 4 +++- 13 files changed, 64 insertions(+), 23 deletions(-) delete mode 100644 e2etest/GuestProxyAgentTest/TestMap/Mariner2-Fips-TestGroup.yml diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index b82055f4..8f2e1ddf 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -380,4 +380,5 @@ xsi xxxx xxxxxxxx xxxxxxxxxxx -zipsas \ No newline at end of file +zipsas +zypper \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 22dfbf71..bc414e62 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -78,7 +78,7 @@ jobs: rpm \ musl-tools \ - sudo snap install dotnet-sdk --classic + sudo apt-get install -y dotnet-sdk-8.0 sudo chown -R root:root /var/lib # Initializes the CodeQL tools for scanning. diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index f5fe69b1..b02fa3af 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -297,7 +297,7 @@ jobs: libssl-dev \ pkg-config \ - sudo snap install dotnet-sdk --classic + sudo apt-get install -y dotnet-sdk-8.0 sudo chown -R root:root /var/lib - name: Run build-linux.sh Debug amd64 diff --git a/e2etest/GuestProxyAgentTest/LinuxScripts/GuestProxyAgentExtensionValidation.sh b/e2etest/GuestProxyAgentTest/LinuxScripts/GuestProxyAgentExtensionValidation.sh index a8399819..9595255b 100644 --- a/e2etest/GuestProxyAgentTest/LinuxScripts/GuestProxyAgentExtensionValidation.sh +++ b/e2etest/GuestProxyAgentTest/LinuxScripts/GuestProxyAgentExtensionValidation.sh @@ -55,6 +55,28 @@ if [[ $os == *"Ubuntu"* ]]; then break fi done +elif [[ $os == *"SUSE"* ]] || [[ $os == *"SLES"* ]]; then + for i in {1..3}; do + echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - start installing jq via zypper $i" + sudo zypper refresh + sudo zypper --non-interactive install jq + if ! command -v jq &>/dev/null; then + echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - zypper install failed, downloading jq binary directly" + arch=$(uname -m) + if [[ "$arch" == "aarch64" ]]; then + jq_arch="arm64" + else + jq_arch="amd64" + fi + sudo curl -L -o /usr/local/bin/jq "https://github.com/jqlang/jq/releases/latest/download/jq-linux-${jq_arch}" + sudo chmod +x /usr/local/bin/jq + fi + sleep 10 + if command -v jq &>/dev/null; then + echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - jq installed successfully" + break + fi + done else for i in {1..3}; do echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - start installing jq via dnf $i" diff --git a/e2etest/GuestProxyAgentTest/TestMap/Mariner2-Fips-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Mariner2-Fips-TestGroup.yml deleted file mode 100644 index 064da755..00000000 --- a/e2etest/GuestProxyAgentTest/TestMap/Mariner2-Fips-TestGroup.yml +++ /dev/null @@ -1,12 +0,0 @@ -groupName: Mariner2-Fips -vmImagePublisher: microsoftcblmariner -vmImageOffer: cbl-mariner -vmImageSku: cbl-mariner-2-gen2-fips -vmImageVersion: latest -scenarios: - - className: GuestProxyAgentTest.TestScenarios.BVTScenario - name: BVTScenario - - name: LinuxPackageScenario - className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario - - name: ProxyAgentExtension - className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Redhat90-Arm64-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Redhat90-Arm64-TestGroup.yml index 1b4e76f5..1a7fec07 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Redhat90-Arm64-TestGroup.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Redhat90-Arm64-TestGroup.yml @@ -7,4 +7,8 @@ scenarios: - className: GuestProxyAgentTest.TestScenarios.BVTScenario name: BVTScenario - name: LinuxPackageScenario - className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario \ No newline at end of file + className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario + - name: ProxyAgentExtension + className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension + - name: LinuxImplicitExtension + className: GuestProxyAgentTest.TestScenarios.LinuxImplicitExtension \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Redhat90-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Redhat90-TestGroup.yml index c77b78c3..c84f4ad3 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Redhat90-TestGroup.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Redhat90-TestGroup.yml @@ -7,4 +7,8 @@ scenarios: - className: GuestProxyAgentTest.TestScenarios.BVTScenario name: BVTScenario - name: LinuxPackageScenario - className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario \ No newline at end of file + className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario + - name: ProxyAgentExtension + className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension + - name: LinuxImplicitExtension + className: GuestProxyAgentTest.TestScenarios.LinuxImplicitExtension \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Rocky9-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Rocky9-TestGroup.yml index 840b577b..2fb00c0c 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Rocky9-TestGroup.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Rocky9-TestGroup.yml @@ -7,4 +7,8 @@ scenarios: - className: GuestProxyAgentTest.TestScenarios.BVTScenario name: BVTScenario - name: LinuxPackageScenario - className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario \ No newline at end of file + className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario + - name: ProxyAgentExtension + className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension + - name: LinuxImplicitExtension + className: GuestProxyAgentTest.TestScenarios.LinuxImplicitExtension \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Suse15SP4-Arm64-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Suse15SP4-Arm64-TestGroup.yml index e9a4f447..45dc69d9 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Suse15SP4-Arm64-TestGroup.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Suse15SP4-Arm64-TestGroup.yml @@ -7,4 +7,8 @@ scenarios: - className: GuestProxyAgentTest.TestScenarios.BVTScenario name: BVTScenario - name: LinuxPackageScenario - className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario \ No newline at end of file + className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario + - name: ProxyAgentExtension + className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension + - name: LinuxImplicitExtension + className: GuestProxyAgentTest.TestScenarios.LinuxImplicitExtension \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Suse15SP4-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Suse15SP4-TestGroup.yml index 2f01767c..69510045 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Suse15SP4-TestGroup.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Suse15SP4-TestGroup.yml @@ -7,4 +7,8 @@ scenarios: - className: GuestProxyAgentTest.TestScenarios.BVTScenario name: BVTScenario - name: LinuxPackageScenario - className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario \ No newline at end of file + className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario + - name: ProxyAgentExtension + className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension + - name: LinuxImplicitExtension + className: GuestProxyAgentTest.TestScenarios.LinuxImplicitExtension \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Ubuntu20-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Ubuntu20-TestGroup.yml index fa781b31..3d20febf 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Ubuntu20-TestGroup.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Ubuntu20-TestGroup.yml @@ -7,4 +7,8 @@ scenarios: - className: GuestProxyAgentTest.TestScenarios.BVTScenario name: BVTScenario - name: LinuxPackageScenario - className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario \ No newline at end of file + className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario + - name: ProxyAgentExtension + className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension + - name: LinuxImplicitExtension + className: GuestProxyAgentTest.TestScenarios.LinuxImplicitExtension \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Ubuntu22-Arm64-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Ubuntu22-Arm64-TestGroup.yml index 8bf9df8f..31a79623 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Ubuntu22-Arm64-TestGroup.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Ubuntu22-Arm64-TestGroup.yml @@ -7,4 +7,8 @@ scenarios: - className: GuestProxyAgentTest.TestScenarios.BVTScenario name: BVTScenario - name: LinuxPackageScenario - className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario \ No newline at end of file + className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario + - name: ProxyAgentExtension + className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension + - name: LinuxImplicitExtension + className: GuestProxyAgentTest.TestScenarios.LinuxImplicitExtension \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Ubuntu22-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Ubuntu22-TestGroup.yml index d5d6f31b..e43092ca 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Ubuntu22-TestGroup.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Ubuntu22-TestGroup.yml @@ -9,4 +9,6 @@ scenarios: - name: LinuxPackageScenario className: GuestProxyAgentTest.TestScenarios.LinuxPackageScenario - name: ProxyAgentExtension - className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension \ No newline at end of file + className: GuestProxyAgentTest.TestScenarios.ProxyAgentExtension + - name: LinuxImplicitExtension + className: GuestProxyAgentTest.TestScenarios.LinuxImplicitExtension \ No newline at end of file From 4a8bbc50436c6c19659ae291c72f647926d91441 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 09:34:57 -0700 Subject: [PATCH 2/5] Bump openssl from 0.10.78 to 0.10.79 (#343) Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.78 to 0.10.79. - [Release notes](https://github.com/rust-openssl/rust-openssl/releases) - [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.78...openssl-v0.10.79) --- updated-dependencies: - dependency-name: openssl dependency-version: 0.10.79 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 109d3775..cf3144ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -831,15 +831,14 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -866,9 +865,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", From 0b5d0bb8347387fec008b39b2df96c68099da522 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Fri, 8 May 2026 17:04:35 -0700 Subject: [PATCH 3/5] Add baked-In test scenario to ensure no proxy if MSP at disabled state (#342) --- .../LinuxScripts/IMDSPingTest.sh | 12 +++- e2etest/GuestProxyAgentTest/Models/TestMap.cs | 1 + .../Scripts/IMDSPingTest.ps1 | 11 ++- .../Settings/TestScenarioSetting.cs | 6 +- .../TestCases/EnableProxyAgentCase.cs | 32 +++++++-- .../TestMap/Test-Map-Linux.yml | 3 +- .../TestMap/Ubuntu24-SGI-TestGroup.yml | 9 +++ .../TestScenarios/BakedInScenario.cs | 34 +++++++++ .../Utilities/TestMapReader.cs | 1 + .../Utilities/VMBuilder.cs | 23 +++++- proxy_agent/src/key_keeper/key.rs | 70 ++++++++++++++++--- 11 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 e2etest/GuestProxyAgentTest/TestMap/Ubuntu24-SGI-TestGroup.yml create mode 100644 e2etest/GuestProxyAgentTest/TestScenarios/BakedInScenario.cs diff --git a/e2etest/GuestProxyAgentTest/LinuxScripts/IMDSPingTest.sh b/e2etest/GuestProxyAgentTest/LinuxScripts/IMDSPingTest.sh index 4d112ec4..3e4d3d3a 100755 --- a/e2etest/GuestProxyAgentTest/LinuxScripts/IMDSPingTest.sh +++ b/e2etest/GuestProxyAgentTest/LinuxScripts/IMDSPingTest.sh @@ -16,18 +16,24 @@ for i in {1..10}; do fi sleep 1 + authorizationHeader=$(curl -s -I -H "Metadata:True" $url | grep -Fi "x-ms-azure-host-authorization") if [ "${imdsSecureChannelEnabled,,}" = "true" ] # case insensitive comparison then - authorizationHeader=$(curl -s -I -H "Metadata:True" $url | grep -Fi "x-ms-azure-host-authorization") if [ "$authorizationHeader" = "" ]; then echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - Response authorization header not exist" exit -1 else - echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - Response authorization header exists" + echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - Response authorization header exists as expected" fi sleep 1 else - echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - IMDS secure channel is not enabled. Skipping x-ms-azure-host-authorization header validation" + if [ "$authorizationHeader" = "" ]; then + echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - Response authorization header not exist as expected" + else + echo "$(date -u +"%Y-%m-%dT%H:%M:%SZ") - Response authorization header exists" + exit -1 + fi + sleep 1 fi done diff --git a/e2etest/GuestProxyAgentTest/Models/TestMap.cs b/e2etest/GuestProxyAgentTest/Models/TestMap.cs index badd0957..0ae85d3c 100644 --- a/e2etest/GuestProxyAgentTest/Models/TestMap.cs +++ b/e2etest/GuestProxyAgentTest/Models/TestMap.cs @@ -19,6 +19,7 @@ public class TestGroupDetails public string VmImageOffer { get; set; } = null!; public string VmImageSku { get; set; } = null!; public string VmImageVersion { get; set; } = null!; + public string SharedGalleryImageUniqueId { get; set; } = null!; public List Scenarios { get; set; } = null!; } diff --git a/e2etest/GuestProxyAgentTest/Scripts/IMDSPingTest.ps1 b/e2etest/GuestProxyAgentTest/Scripts/IMDSPingTest.ps1 index 5f1b3185..4d9dcae5 100644 --- a/e2etest/GuestProxyAgentTest/Scripts/IMDSPingTest.ps1 +++ b/e2etest/GuestProxyAgentTest/Scripts/IMDSPingTest.ps1 @@ -23,8 +23,8 @@ while ($i -lt 10) { exit -1 } + $responseHeaders = $response.Headers if ("$imdsSecureChannelEnabled" -ieq "true") { # case insensitive comparison - $responseHeaders = $response.Headers if ($null -eq $responseHeaders["x-ms-azure-host-authorization"]) { Write-Error "$((Get-Date).ToUniversalTime()) - Ping test failed. Response does not contain x-ms-azure-host-authorization header" exit -1 @@ -32,10 +32,15 @@ while ($i -lt 10) { else { Write-Output "$((Get-Date).ToUniversalTime()) - Ping test passed. Response contains x-ms-azure-host-authorization header" } - } else { - Write-Output "$((Get-Date).ToUniversalTime()) - IMDS secure channel is not enabled. Skipping x-ms-azure-host-authorization header validation" + if ($null -eq $responseHeaders["x-ms-azure-host-authorization"]) { + Write-Output "$((Get-Date).ToUniversalTime()) - Ping test passed. Response does not contain x-ms-azure-host-authorization header as expected" + } + else { + Write-Error "$((Get-Date).ToUniversalTime()) - Ping test failed. Response contains x-ms-azure-host-authorization header" + exit -1 + } } $webRequest.Abort() diff --git a/e2etest/GuestProxyAgentTest/Settings/TestScenarioSetting.cs b/e2etest/GuestProxyAgentTest/Settings/TestScenarioSetting.cs index 9772c200..e37cfb73 100644 --- a/e2etest/GuestProxyAgentTest/Settings/TestScenarioSetting.cs +++ b/e2etest/GuestProxyAgentTest/Settings/TestScenarioSetting.cs @@ -13,6 +13,7 @@ public class TestScenarioSetting internal string vmImageOffer = ""; internal string vmImageSku = ""; internal string vmImageVersion = ""; + internal string sharedGalleryImageUniqueId = ""; internal string suffixName = new Random().Next(1000).ToString(); internal string testScenarioClassName = "GuestProxyAgentTest.TestScenarios.BVTScenario"; internal int testScenarioTimeoutMilliseconds = 1000 * 60 * 120; @@ -26,7 +27,8 @@ internal VMImageDetails VMImageDetails Publisher = vmImagePublisher, Offer = vmImageOffer, Sku = vmImageSku, - Version = vmImageVersion + Version = vmImageVersion, + SharedGalleryImageUniqueId = sharedGalleryImageUniqueId }; } } @@ -54,11 +56,13 @@ public class VMImageDetails public string Offer { get; set; } = null!; public string Sku { get; set; } = null!; public string Version { get; set; } = null!; + public string SharedGalleryImageUniqueId { get; set; } = null!; public bool IsArm64 { get { + // TODO: SharedGalleryImageUniqueId also contains architecture info, need to parse it when it's available return (Offer == null ? false : Offer.Contains("arm64", StringComparison.OrdinalIgnoreCase)) || (Sku == null ? false : Sku.Contains("arm64", StringComparison.OrdinalIgnoreCase)); } diff --git a/e2etest/GuestProxyAgentTest/TestCases/EnableProxyAgentCase.cs b/e2etest/GuestProxyAgentTest/TestCases/EnableProxyAgentCase.cs index 1373465e..f6de2e1a 100644 --- a/e2etest/GuestProxyAgentTest/TestCases/EnableProxyAgentCase.cs +++ b/e2etest/GuestProxyAgentTest/TestCases/EnableProxyAgentCase.cs @@ -52,14 +52,34 @@ public override async Task StartAsync(TestCaseExecutionContext context) if (EnableProxyAgent) { // property 'inVMAccessControlProfileReferenceId' cannot be used together with property 'mode' - patch.SecurityProfile.ProxyAgentSettings.WireServer = new HostEndpointSettings + if (string.IsNullOrEmpty(TestSetting.Instance.InVmWireServerAccessControlProfileReferenceId)) { - InVmAccessControlProfileReferenceId = TestSetting.Instance.InVmWireServerAccessControlProfileReferenceId, - }; - patch.SecurityProfile.ProxyAgentSettings.Imds = new HostEndpointSettings + patch.SecurityProfile.ProxyAgentSettings.WireServer = new HostEndpointSettings + { + Mode = HostEndpointSettingsMode.Enforce, + }; + } + else + { + patch.SecurityProfile.ProxyAgentSettings.WireServer = new HostEndpointSettings + { + InVmAccessControlProfileReferenceId = TestSetting.Instance.InVmWireServerAccessControlProfileReferenceId, + }; + } + if (string.IsNullOrEmpty(TestSetting.Instance.InVmIMDSAccessControlProfileReferenceId)) { - InVmAccessControlProfileReferenceId = TestSetting.Instance.InVmIMDSAccessControlProfileReferenceId, - }; + patch.SecurityProfile.ProxyAgentSettings.Imds = new HostEndpointSettings + { + Mode = HostEndpointSettingsMode.Enforce, + }; + } + else + { + patch.SecurityProfile.ProxyAgentSettings.Imds = new HostEndpointSettings + { + InVmAccessControlProfileReferenceId = TestSetting.Instance.InVmIMDSAccessControlProfileReferenceId, + }; + } } await vmr.UpdateAsync(Azure.WaitUntil.Completed, patch, cancellationToken: context.CancellationToken); diff --git a/e2etest/GuestProxyAgentTest/TestMap/Test-Map-Linux.yml b/e2etest/GuestProxyAgentTest/TestMap/Test-Map-Linux.yml index 3cd46b95..18130f29 100644 --- a/e2etest/GuestProxyAgentTest/TestMap/Test-Map-Linux.yml +++ b/e2etest/GuestProxyAgentTest/TestMap/Test-Map-Linux.yml @@ -5,4 +5,5 @@ testGroupList: - include: Ubuntu20-TestGroup.yml - include: Redhat90-TestGroup.yml - include: Suse15SP4-TestGroup.yml - - include: Rocky9-TestGroup.yml \ No newline at end of file + - include: Rocky9-TestGroup.yml + - include: Ubuntu24-SGI-TestGroup.yml \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestMap/Ubuntu24-SGI-TestGroup.yml b/e2etest/GuestProxyAgentTest/TestMap/Ubuntu24-SGI-TestGroup.yml new file mode 100644 index 00000000..3790fb3a --- /dev/null +++ b/e2etest/GuestProxyAgentTest/TestMap/Ubuntu24-SGI-TestGroup.yml @@ -0,0 +1,9 @@ +groupName: Ubuntu24-SGI +vmImagePublisher: +vmImageOffer: +vmImageSku: +vmImageVersion: +sharedGalleryImageUniqueId: /SharedGalleries/0a2c89a7-a44e-4cd0-b6ec-868432ad1d13-proxyagent/images/ubuntu-2404-gen2/versions/latest +scenarios: + - className: GuestProxyAgentTest.TestScenarios.BakedInScenario + name: BakedInScenario \ No newline at end of file diff --git a/e2etest/GuestProxyAgentTest/TestScenarios/BakedInScenario.cs b/e2etest/GuestProxyAgentTest/TestScenarios/BakedInScenario.cs new file mode 100644 index 00000000..f763037a --- /dev/null +++ b/e2etest/GuestProxyAgentTest/TestScenarios/BakedInScenario.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT +using GuestProxyAgentTest.TestCases; +using GuestProxyAgentTest.Utilities; +using System.Threading.Channels; + +namespace GuestProxyAgentTest.TestScenarios +{ + public class BakedInScenario : TestScenarioBase + { + public override void TestScenarioSetup() + { + if (Constants.IS_WINDOWS()) + { + throw new InvalidOperationException("GPA BakedIn scenario can only run on Linux VMs."); + } + + var secureChannelEnabled = false; + EnableProxyAgentForNewVM = false; + AddTestCase(new GuestProxyAgentValidationCase("GuestProxyAgentValidationWithoutMSP", "disabled")); + AddTestCase(new IMDSPingTestCase("IMDSPingTestBeforeEnableMSP", secureChannelEnabled)); + + // enable secure channel after validation to test IMDS connectivity with secure channel enabled, + AddTestCase(new EnableProxyAgentCase()); + secureChannelEnabled = true; + AddTestCase(new GuestProxyAgentValidationCase("GuestProxyAgentValidationWithSecureChannelEnabled", "WireServer Enforce - IMDS Enforce - HostGA Enforce")); + AddTestCase(new IMDSPingTestCase("IMDSPingTestBeforeReboot", secureChannelEnabled)); + + // then reboot to verify the secure channel state is preserved across reboots + AddTestCase(new RebootVMCase("RebootVMCaseAfterEnableMSP")); + AddTestCase(new IMDSPingTestCase("IMDSPingTestAfterReboot", secureChannelEnabled)); + } + } +} diff --git a/e2etest/GuestProxyAgentTest/Utilities/TestMapReader.cs b/e2etest/GuestProxyAgentTest/Utilities/TestMapReader.cs index 733de361..31f81b81 100644 --- a/e2etest/GuestProxyAgentTest/Utilities/TestMapReader.cs +++ b/e2etest/GuestProxyAgentTest/Utilities/TestMapReader.cs @@ -49,6 +49,7 @@ public static List ReadFlattenTestScenarioSettingFromTestMa vmImagePublisher = group.VmImagePublisher, vmImageSku = group.VmImageSku, vmImageVersion = group.VmImageVersion, + sharedGalleryImageUniqueId = group.SharedGalleryImageUniqueId, testGroupName = group.GroupName, testScenarioClassName = ele.ClassName, testScenarioName = ele.Name, diff --git a/e2etest/GuestProxyAgentTest/Utilities/VMBuilder.cs b/e2etest/GuestProxyAgentTest/Utilities/VMBuilder.cs index eace53cd..c76f5fc1 100644 --- a/e2etest/GuestProxyAgentTest/Utilities/VMBuilder.cs +++ b/e2etest/GuestProxyAgentTest/Utilities/VMBuilder.cs @@ -226,6 +226,16 @@ private async Task DoCreateVMData(TestLogger logger, Resourc NetworkProfile = await DoCreateVMNetWorkProfile(logger, rgr), }; + var useSGI = !string.IsNullOrEmpty(this.testScenarioSetting.VMImageDetails.SharedGalleryImageUniqueId); + // Use Shared Gallery Image if SharedGalleryImageUniqueId is provided in the test setting + if (useSGI) + { + vmData.StorageProfile.ImageReference = new ImageReference() + { + SharedGalleryImageUniqueId = new ResourceIdentifier(this.testScenarioSetting.VMImageDetails.SharedGalleryImageUniqueId) + }; + } + if (enableProxyAgent) { vmData.SecurityProfile = new SecurityProfile() @@ -243,12 +253,23 @@ private async Task DoCreateVMData(TestLogger logger, Resourc }, } }; - if (!Constants.IS_WINDOWS()) + if (!Constants.IS_WINDOWS() && !useSGI) { // Only Linux VMs support flag 'AddProxyAgentExtension', // Windows VMs always have the GPA VM Extension installed when ProxyAgentSettings.Enabled is true. vmData.SecurityProfile.ProxyAgentSettings.AddProxyAgentExtension = true; } + + // If the access control profile reference id is not set, set the mode to Enforce to make sure the proxy agent is working in expected way. + // This is for test images which don't have the access control profile set up, or for shared gallery images which can't reference the access control profile in other subscription. + if (string.IsNullOrEmpty(vmData.SecurityProfile.ProxyAgentSettings.WireServer.InVmAccessControlProfileReferenceId)) + { + vmData.SecurityProfile.ProxyAgentSettings.WireServer.Mode = HostEndpointSettingsMode.Enforce; + } + if (string.IsNullOrEmpty(vmData.SecurityProfile.ProxyAgentSettings.Imds.InVmAccessControlProfileReferenceId)) + { + vmData.SecurityProfile.ProxyAgentSettings.Imds.Mode = HostEndpointSettingsMode.Enforce; + } } if (Constants.IS_WINDOWS()) diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index cc850e55..4079ae68 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -42,6 +42,7 @@ use std::{ffi::OsString, time::Duration}; const AUDIT_MODE: &str = "audit"; const ENFORCE_MODE: &str = "enforce"; +const DISABLED_MODE: &str = "disabled"; //const ALLOW_DEFAULT_ACCESS: &str = "allow"; //const DENY_DEFAULT_ACCESS: &str = "deny"; @@ -63,7 +64,7 @@ pub struct KeyStatus { // specifies what keys are expected for telemetry purposes. // Exact values are TBD, but could include things like user id. requiredClaimsHeaderPairs: Option>, - // One of Disabled, Wireserver, WireserverAndImds. valid at version 1.0 + // One of Disabled, Audit, Wireserver, WireserverAndImds. valid at version 1.0 #[serde(skip_serializing_if = "Option::is_none")] pub secureChannelState: Option, // Indicates if the secure channel is enabled. valid at version 2.0 @@ -605,19 +606,23 @@ impl KeyStatus { match &self.authorizationRules { Some(rules) => match &rules.wireserver { Some(item) => item.mode.to_lowercase(), - None => "disabled".to_string(), + None => DISABLED_MODE.to_string(), }, - None => "disabled".to_string(), + None => DISABLED_MODE.to_string(), } } else { + // in older version: secureChannelState indicates what endpoints have secure channel protections enabled. + // One of Disabled, Audit, Wireserver, WireserverAndImds. let state = match &self.secureChannelState { Some(s) => s.to_lowercase(), - None => "disabled".to_string(), + None => DISABLED_MODE.to_string(), }; if state == "wireserver" || state == "wireserverandimds" { ENFORCE_MODE.to_string() - } else { + } else if state == "audit" { AUDIT_MODE.to_string() + } else { + DISABLED_MODE.to_string() } } } @@ -627,18 +632,25 @@ impl KeyStatus { match &self.authorizationRules { Some(rules) => match &rules.imds { Some(item) => item.mode.to_lowercase(), - None => "disabled".to_string(), + None => DISABLED_MODE.to_string(), }, - None => "disabled".to_string(), + None => DISABLED_MODE.to_string(), } } else { + // in older version: secureChannelState indicates what endpoints have secure channel protections enabled. + // One of Disabled, Audit, Wireserver, WireserverAndImds. let state = match &self.secureChannelState { Some(s) => s.to_lowercase(), - None => "disabled".to_string(), + None => DISABLED_MODE.to_string(), }; - if state == "wireserverandimds" { + + if state == DISABLED_MODE { + DISABLED_MODE.to_string() + } else if state == "wireserverandimds" { ENFORCE_MODE.to_string() } else { + // audit mode when secureChannelState is audit or wireserver, + // because in both cases IMDS has some level of protection, just not enforce. AUDIT_MODE.to_string() } } @@ -658,7 +670,7 @@ impl KeyStatus { impl Display for KeyStatus { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, - "authorizationScheme: {}, keyDeliveryMethod: {}, keyGuid: {}, secureChannelState: {}, version: {}", + "authorizationScheme: {}, keyDeliveryMethod: {}, keyGuid: {}, secureChannelState: {}, version: {}, WireServerMode: {}, IMDSMode: {}", self.authorizationScheme, self.keyDeliveryMethod, match &self.keyGuid { @@ -666,7 +678,9 @@ impl Display for KeyStatus { None => "None".to_string(), }, self.get_secure_channel_state(), - self.version) + self.version, + self.get_wire_server_mode(), + self.get_imds_mode()) } } @@ -935,10 +949,44 @@ mod tests { "WireServer mode mismatch" ); assert_eq!(status_v1.get_imds_mode(), "audit", "IMDS mode mismatch"); + + // Test the case when secureChannelState is Disabled, both WireServer and IMDS should be in disabled mode. + let status_response_v1 = r#"{ + "authorizationScheme": "Azure-HMAC-SHA256", + "keyDeliveryMethod": "http", + "keyGuid": null, + "requiredClaimsHeaderPairs": null, + "secureChannelState": "Disabled", + "version": "1.0" + }"#; + let status_v1: KeyStatus = serde_json::from_str(status_response_v1).unwrap(); + assert_eq!( + super::DISABLED_MODE.to_string(), + status_v1.get_wire_server_mode(), + "WireServer mode mismatch when secureChannelState is Disabled" + ); + assert_eq!( + super::DISABLED_MODE.to_string(), + status_v1.get_imds_mode(), + "IMDS mode mismatch when secureChannelState is Disabled" + ); } #[test] fn key_status_v2_test() { + let status_response = r#"{"authorizationRules":null,"authorizationScheme":"Azure-HMAC-SHA256","keyDeliveryMethod":"http","keyGuid":null,"keyIncarnationId":null,"requiredClaimsHeaderPairs":[],"secureChannelEnabled":false,"version":"2.0"}"#; + let status: KeyStatus = serde_json::from_str(status_response).unwrap(); + assert_eq!( + "disabled", + status.get_wire_server_mode(), + "WireServer mode mismatch when secureChannelEnabled is false" + ); + assert_eq!( + "disabled", + status.get_imds_mode(), + "IMDS mode mismatch when secureChannelEnabled is false" + ); + let status_response = r#"{ "authorizationScheme": "Azure-HMAC-SHA256", "keyDeliveryMethod": "http", From 6111245339d6453dbfbedf6585dad001aa55ce58 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Fri, 8 May 2026 17:19:25 -0700 Subject: [PATCH 4/5] Percent-decode request paths and query parameters before access-control privilege matching (#344) --- .github/actions/spelling/expect.txt | 6 + Cargo.lock | 7 ++ proxy_agent/Cargo.toml | 1 + proxy_agent/src/key_keeper/key.rs | 118 +++++++++++++++++-- proxy_agent/src/proxy/authorization_rules.rs | 107 ++++++++++++++++- proxy_agent_shared/src/hyper_client.rs | 12 ++ 6 files changed, 241 insertions(+), 10 deletions(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 8f2e1ddf..2dc59a05 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -106,12 +106,15 @@ FFFFFFFF fffi ffi FIXEDFILEINFO +Fmanagement FOF +Fresource FSETID FSO fsprogs fstorage fstype +ftoken fwlink Fzpeng gaplugin @@ -312,6 +315,7 @@ tokio topdir totalentries transitioning +trustyuser UBR UBRSTRING udev @@ -323,6 +327,7 @@ Unregistering unregisters unspec uzers +valu VCpus vcruntime vendored @@ -365,6 +370,7 @@ WMI workarounds WORKINGSET WORKDIR +wrongvalue WScript wsf Wsh diff --git a/Cargo.lock b/Cargo.lock index cf3144ce..2a6ecf89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,7 @@ dependencies = [ "libloading", "nix", "once_cell", + "percent-encoding", "proxy_agent_shared", "regex", "serde", @@ -887,6 +888,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.14" diff --git a/proxy_agent/Cargo.toml b/proxy_agent/Cargo.toml index 9bc6b227..31c6095b 100644 --- a/proxy_agent/Cargo.toml +++ b/proxy_agent/Cargo.toml @@ -30,6 +30,7 @@ thiserror = "1.0.64" libc = "0.2.147" socket2 = "0.5" # Set socket options without tokio/std conversion base64 = "0.22" +percent-encoding = "2.3" [dependencies.uuid] version = "1.3.0" diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index 4079ae68..371ca2ae 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -216,7 +216,7 @@ impl Clone for Privilege { impl Privilege { /// Note: `self.path` and `self.queryParameters` keys/values are expected to be /// pre-lowercased (done in `ComputedAuthorizationItem::from_authorization_item`). - /// `lowered_request_path` should be `request_url.path().to_lowercase()`, hoisted by the caller. + /// `lowered_request_path` should be the percent-decoded, lowercased request path. pub fn is_match( &self, logger: &mut ConnectionLogger, @@ -227,7 +227,18 @@ impl Privilege { LoggerLevel::Trace, format!("Start to match privilege '{}'", self.name), ); - if lowered_request_path.starts_with(&self.path) { + + // The decoded path may contain '?' if the attacker encoded it as %3F. + // Split so we match only the path portion, and extract any embedded query parameters. + let (actual_path, embedded_query) = match lowered_request_path.find('?') { + Some(pos) => ( + &lowered_request_path[..pos], + Some(&lowered_request_path[pos + 1..]), + ), + None => (lowered_request_path, None), + }; + + if actual_path.starts_with(&self.path) { logger.write( LoggerLevel::Trace, format!("Matched privilege path '{}'", self.path), @@ -242,15 +253,35 @@ impl Privilege { ), ); + // Collect query pairs from the URI query string. + let mut all_query_pairs = hyper_client::query_pairs(request_url); + + // Also collect query pairs embedded in the decoded path (from encoded %3F). + // These are already percent-decoded and lowercased from lowered_request_path. + if let Some(eq) = embedded_query { + for pair in eq.split('&') { + let mut split = pair.splitn(2, '='); + let key = split.next().unwrap_or(""); + if key.is_empty() { + continue; + } + let value = split.next().unwrap_or(""); + all_query_pairs.push((key.to_string(), value.to_string())); + } + } + for (key, value) in query_parameters { - // We may need to optimize this like `lowered_request_path` if there are too many query parameters in the future, - // but currently we expect only a few query parameters at most, so the performance impact should be minimal. - match hyper_client::query_pairs(request_url) - .into_iter() - .find(|(k, _)| k.to_lowercase() == *key) - { + // Percent-decode query keys/values before matching to prevent encoded bypass attacks. + match all_query_pairs.iter().find(|(k, _)| { + percent_encoding::percent_decode_str(k) + .decode_utf8_lossy() + .to_lowercase() + == *key + }) { Some((_, v)) => { - if v.to_lowercase() == *value { + let decoded_v = + percent_encoding::percent_decode_str(v).decode_utf8_lossy(); + if decoded_v.to_lowercase() == *value { logger.write( LoggerLevel::Trace, format!( @@ -873,6 +904,7 @@ pub async fn attest_key(host: &str, port: u16, key: &Key) -> Result<()> { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::ffi::OsString; #[cfg(not(windows))] use std::os::unix::ffi::OsStringExt; @@ -1533,6 +1565,74 @@ mod tests { !privilege2.is_match(&mut logger, &url, &url.path().to_lowercase()), "privilege should not be matched" ); + + // Test percent-encoded query key: key1 encoded as k%65y1 should still match + let url: Uri = "http://localhost/test?k%65y1=value1&key2=value2" + .parse() + .unwrap(); + assert!( + privilege.is_match(&mut logger, &url, &url.path().to_lowercase()), + "percent-encoded query key should match" + ); + + // Test percent-encoded query value: value1 encoded as valu%651 should still match + let url: Uri = "http://localhost/test?key1=valu%651&key2=value2" + .parse() + .unwrap(); + assert!( + privilege.is_match(&mut logger, &url, &url.path().to_lowercase()), + "percent-encoded query value should match" + ); + + // Test percent-encoded slash in query value: resource=https%3A%2F%2Fmanagement.azure.com%2F + let privilege_with_resource = Privilege { + name: "token".to_string(), + path: "/metadata/identity/oauth2/token".to_string(), + queryParameters: Some(HashMap::from([( + "resource".to_string(), + "https://management.azure.com/".to_string(), + )])), + }; + let url: Uri = "http://169.254.169.254/metadata/identity/oauth2/token?resource=https%3A%2F%2Fmanagement.azure.com%2F" + .parse() + .unwrap(); + assert!( + privilege_with_resource.is_match(&mut logger, &url, &url.path().to_lowercase()), + "percent-encoded slashes/colons in query value should match decoded privilege" + ); + + // Test both key and value percent-encoded simultaneously + let url: Uri = "http://localhost/test?k%65y1=valu%651&k%65y2=valu%652" + .parse() + .unwrap(); + assert!( + privilege.is_match(&mut logger, &url, &url.path().to_lowercase()), + "both percent-encoded key and value should match" + ); + + // Test encoded key that does NOT match should still fail + let url: Uri = "http://localhost/test?k%65y1=value1&k%65y2=wrongvalue" + .parse() + .unwrap(); + assert!( + !privilege.is_match(&mut logger, &url, &url.path().to_lowercase()), + "percent-encoded key with wrong value should not match" + ); + + // Test encoded '?' (%3F) in path: IMDS decodes the full URL so the query params are real. + // The caller (authorization_rules.rs) percent-decodes the path before passing it here, + // so lowered_request_path will contain '?' from the decoded %3F. + let url: Uri = "http://169.254.169.254/metadata/identity/oauth2/token%3Fresource=https%3A%2F%2Fmanagement.azure.com%2F" + .parse() + .unwrap(); + // Simulate what authorization_rules.rs does: percent-decode then lowercase + let decoded_path = percent_encoding::percent_decode_str(url.path()) + .decode_utf8_lossy() + .to_lowercase(); + assert!( + privilege_with_resource.is_match(&mut logger, &url, &decoded_path), + "encoded %3F query separator must be decoded and query params matched" + ); } #[tokio::test] diff --git a/proxy_agent/src/proxy/authorization_rules.rs b/proxy_agent/src/proxy/authorization_rules.rs index 3c706083..857c3ade 100644 --- a/proxy_agent/src/proxy/authorization_rules.rs +++ b/proxy_agent/src/proxy/authorization_rules.rs @@ -191,7 +191,9 @@ impl ComputedAuthorizationItem { return true; } - let lowered_request_path = request_url.path().to_lowercase(); + let decoded_path = + percent_encoding::percent_decode_str(request_url.path()).decode_utf8_lossy(); + let lowered_request_path = decoded_path.to_lowercase(); let mut any_privilege_matched = false; for privilege in self.privileges.values() { let privilege_name = &privilege.name; @@ -635,4 +637,107 @@ mod tests { // clean up and ignore the clean up errors _ = std::fs::remove_dir_all(&temp_test_path); } + + #[tokio::test] + async fn test_percent_encoded_path_must_not_bypass_privilege() { + let mut test_logger = ConnectionLogger::new(0, 0); + + // Simulate a privilege restricting /metadata/identity/oauth2/token + let access_control_rules = AccessControlRules { + roles: Some(vec![Role { + name: "tokenRole".to_string(), + privileges: vec!["tokenPrivilege".to_string()], + }]), + privileges: Some(vec![Privilege { + name: "tokenPrivilege".to_string(), + path: "/metadata/identity/oauth2/token".to_string(), + queryParameters: None, + }]), + identities: Some(vec![Identity { + name: "trustedUser".to_string(), + userName: Some("trustyuser".to_string()), + groupName: None, + exePath: None, + processName: None, + }]), + roleAssignments: Some(vec![RoleAssignment { + role: "tokenRole".to_string(), + identities: vec!["trustedUser".to_string()], + }]), + }; + let authorization_item = AuthorizationItem { + defaultAccess: "allow".to_string(), + mode: "enforce".to_string(), + rules: Some(access_control_rules), + id: "0".to_string(), + }; + let rules = ComputedAuthorizationItem::from_authorization_item(authorization_item); + + let attacker_claims = Claims { + userId: 9999, + userName: "attacker".to_string(), + userGroups: vec!["users".to_string()], + processId: 1234, + processFullPath: PathBuf::from("/usr/bin/curl"), + clientIp: "127.0.0.1".to_string(), + clientPort: 12345, + processName: OsString::from("curl"), + processCmdLine: "curl".to_string(), + runAsElevated: false, + }; + + // Normal path is correctly denied for attacker + let url = hyper::Uri::from_str("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/").unwrap(); + assert!( + !rules.is_allowed(&mut test_logger, url, attacker_claims.clone()), + "Normal path must be denied for attacker" + ); + + // Percent-encoded %2F bypass: must also be denied + let url_encoded = hyper::Uri::from_str("http://169.254.169.254/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/").unwrap(); + assert!( + !rules.is_allowed(&mut test_logger, url_encoded, attacker_claims.clone()), + "Percent-encoded path (%2F) must NOT bypass privilege matching" + ); + + // Mixed encoding: %2f (lowercase hex) must also be caught + let url_lower_hex = hyper::Uri::from_str("http://169.254.169.254/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/").unwrap(); + assert!( + !rules.is_allowed(&mut test_logger, url_lower_hex, attacker_claims.clone()), + "Percent-encoded path (%2f lowercase) must NOT bypass privilege matching" + ); + + // Trusted user should still be allowed through normal path + let trusted_claims = Claims { + userId: 1000, + userName: "trustyuser".to_string(), + userGroups: vec!["users".to_string()], + processId: 5678, + processFullPath: PathBuf::from("/usr/bin/curl"), + clientIp: "127.0.0.1".to_string(), + clientPort: 12345, + processName: OsString::from("curl"), + processCmdLine: "curl".to_string(), + runAsElevated: false, + }; + let url = hyper::Uri::from_str("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/").unwrap(); + assert!( + rules.is_allowed(&mut test_logger, url, trusted_claims.clone()), + "Trusted user must be allowed through normal path" + ); + + // Trusted user should still be allowed through percent-encoded path (%2F) + let url = hyper::Uri::from_str("http://169.254.169.254/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/").unwrap(); + assert!( + rules.is_allowed(&mut test_logger, url, trusted_claims.clone()), + "Trusted user must be allowed through percent-encoded path (%2F)" + ); + + // Trusted user should still be allowed through percent-encoded path (%2f) with lowercase hex + let url = hyper::Uri::from_str("http://169.254.169.254/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/").unwrap(); + assert!( + rules.is_allowed(&mut test_logger, url, trusted_claims.clone()), + "Trusted user must be allowed through percent-encoded path (%2f)" + ); + } } diff --git a/proxy_agent_shared/src/hyper_client.rs b/proxy_agent_shared/src/hyper_client.rs index 71175448..fb9e2744 100644 --- a/proxy_agent_shared/src/hyper_client.rs +++ b/proxy_agent_shared/src/hyper_client.rs @@ -714,4 +714,16 @@ mod tests { "sync time should be close to the custom header time" ); } + + #[test] + fn query_pairs_test() { + let url = "/test?key1=value1&key%202=value%202" + .parse::() + .unwrap(); + let pairs = super::query_pairs(&url); + assert_eq!(pairs.len(), 2); + assert_eq!(pairs[0], ("key1".to_string(), "value1".to_string())); + // query_pairs returns raw (non-decoded) values for signature compatibility + assert_eq!(pairs[1], ("key%202".to_string(), "value%202".to_string())); + } } From 18448a04cd55f19af73532af81f6957552d3c68f Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Mon, 11 May 2026 11:35:53 -0700 Subject: [PATCH 5/5] Update product version to 1.0.44 --- Cargo.lock | 8 ++++---- proxy_agent/Cargo.toml | 2 +- proxy_agent_extension/Cargo.toml | 2 +- proxy_agent_setup/Cargo.toml | 2 +- proxy_agent_shared/Cargo.toml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8cd0ff37..4f62b972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "ProxyAgentExt" -version = "1.0.43" +version = "1.0.44" dependencies = [ "clap", "ctor", @@ -172,7 +172,7 @@ dependencies = [ [[package]] name = "azure-proxy-agent" -version = "1.0.43" +version = "1.0.44" dependencies = [ "aya", "base64", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "proxy_agent_setup" -version = "1.0.43" +version = "1.0.44" dependencies = [ "clap", "proxy_agent_shared", @@ -950,7 +950,7 @@ dependencies = [ [[package]] name = "proxy_agent_shared" -version = "1.0.43" +version = "1.0.44" dependencies = [ "chrono", "concurrent-queue", diff --git a/proxy_agent/Cargo.toml b/proxy_agent/Cargo.toml index 80dd93c4..a18cb85d 100644 --- a/proxy_agent/Cargo.toml +++ b/proxy_agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "azure-proxy-agent" -version = "1.0.43" # always 3-number version +version = "1.0.44" # always 3-number version edition = "2021" build = "build.rs" readme = "README.md" diff --git a/proxy_agent_extension/Cargo.toml b/proxy_agent_extension/Cargo.toml index c9194ac1..a9c5aa05 100644 --- a/proxy_agent_extension/Cargo.toml +++ b/proxy_agent_extension/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ProxyAgentExt" -version = "1.0.43" # always 3-number version +version = "1.0.44" # always 3-number version edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/proxy_agent_setup/Cargo.toml b/proxy_agent_setup/Cargo.toml index acc434b0..1ce88cfd 100644 --- a/proxy_agent_setup/Cargo.toml +++ b/proxy_agent_setup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxy_agent_setup" -version = "1.0.43" +version = "1.0.44" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/proxy_agent_shared/Cargo.toml b/proxy_agent_shared/Cargo.toml index 28106c9d..ad4aa5df 100644 --- a/proxy_agent_shared/Cargo.toml +++ b/proxy_agent_shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxy_agent_shared" -version = "1.0.43" +version = "1.0.44" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html