diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Multitenancy/TenantManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Multitenancy/TenantManagerTest.cs index 6f1d6885..c44e440a 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Multitenancy/TenantManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Multitenancy/TenantManagerTest.cs @@ -43,12 +43,11 @@ public class TenantManagerTest "...", "..\n", "tenant\n", - "../other", - "victim.", + "tenant.", "tenant/1", "tenant?foo=bar", "tenant#frag", - "tenant%2e%2e", + "tenant%2f1", "tenant\\1", }; @@ -283,7 +282,7 @@ public async Task UpdateTenant(TestConfig config) Assert.Single(handler.Requests); var request = handler.Requests[0]; Assert.Equal(HttpUtils.Patch, request.Method); - var mask = "allowPasswordSignup,displayName,enableEmailLinkSignin"; + var mask = "allowPasswordSignup%2CdisplayName%2CenableEmailLinkSignin"; config.AssertRequest($"tenants/tenant1?updateMask={mask}", request); var body = NewtonsoftJsonSerializer.Instance.Deserialize( @@ -598,7 +597,7 @@ public void ListTenantsOptions(TestConfig config) var customOptions = new ListTenantsOptions() { PageSize = 3, - PageToken = "custom-token", + PageToken = "custom+token=1&2#3", }; var pagedEnumerable = auth.TenantManager.ListTenantsAsync(customOptions); @@ -611,7 +610,8 @@ public void ListTenantsOptions(TestConfig config) Assert.All(tenants, AssertTenant); Assert.Equal(2, handler.Requests.Count); - config.AssertRequest("tenants?pageSize=3&pageToken=custom-token", handler.Requests[0]); + config.AssertRequest( + "tenants?pageSize=3&pageToken=custom%2Btoken%3D1%262%233", handler.Requests[0]); config.AssertRequest("tenants?pageSize=3&pageToken=token", handler.Requests[1]); } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Providers/OidcProviderConfigTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Providers/OidcProviderConfigTest.cs index 1ecba3b7..911293e2 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Providers/OidcProviderConfigTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Providers/OidcProviderConfigTest.cs @@ -38,10 +38,10 @@ public class OidcProviderConfigTest "oidc.\n", "oidc.provider\n", "oidc.provider.", - "oidc.provider/../other", + "oidc.provider/1", "oidc.provider?foo=bar", "oidc.provider#frag", - "oidc.provider%2fother", + "oidc.provider%2f1", "oidc.provider&extra=1", }; @@ -314,7 +314,7 @@ public async Task UpdateConfig(ProviderTestConfig config) Assert.Single(handler.Requests); var request = handler.Requests[0]; Assert.Equal(ProviderTestConfig.PatchMethod, request.Method); - var mask = "clientId,clientSecret,displayName,enabled,issuer,responseType.code,responseType.idToken"; + var mask = "clientId%2CclientSecret%2CdisplayName%2Cenabled%2Cissuer%2CresponseType.code%2CresponseType.idToken"; config.AssertRequest($"oauthIdpConfigs/oidc.provider?updateMask={mask}", request); var body = NewtonsoftJsonSerializer.Instance.Deserialize( @@ -640,7 +640,7 @@ public void ListOidcOptions(ProviderTestConfig config) var customOptions = new ListProviderConfigsOptions() { PageSize = 3, - PageToken = "custom-token", + PageToken = "custom+token=1&2#3", }; var pagedEnumerable = auth.ListOidcProviderConfigsAsync(customOptions); @@ -654,7 +654,7 @@ public void ListOidcOptions(ProviderTestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest( - "oauthIdpConfigs?pageSize=3&pageToken=custom-token", handler.Requests[0]); + "oauthIdpConfigs?pageSize=3&pageToken=custom%2Btoken%3D1%262%233", handler.Requests[0]); config.AssertRequest( "oauthIdpConfigs?pageSize=3&pageToken=token", handler.Requests[1]); } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Providers/SamlProviderConfigTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Providers/SamlProviderConfigTest.cs index 192fc195..341fd2d6 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Providers/SamlProviderConfigTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Providers/SamlProviderConfigTest.cs @@ -35,10 +35,10 @@ public class SamlProviderConfigTest "saml.\n", "saml.provider\n", "saml.provider.", - "saml.provider/../other", + "saml.provider/1", "saml.provider?foo=bar", "saml.provider#frag", - "saml.provider%2fother", + "saml.provider%2f1", "saml.provider&extra=1", }; @@ -315,8 +315,8 @@ public async Task UpdateConfig(ProviderTestConfig config) this.AssertSamlProviderConfig(provider); var request = Assert.Single(handler.Requests); Assert.Equal(ProviderTestConfig.PatchMethod, request.Method); - var mask = "displayName,enabled,idpConfig.idpCertificates,idpConfig.idpEntityId,idpConfig.ssoUrl," - + "spConfig.callbackUri,spConfig.spEntityId"; + var mask = "displayName%2Cenabled%2CidpConfig.idpCertificates%2CidpConfig.idpEntityId%2CidpConfig.ssoUrl%2C" + + "spConfig.callbackUri%2CspConfig.spEntityId"; config.AssertRequest( $"inboundSamlConfigs/saml.provider?updateMask={mask}", request); @@ -606,7 +606,7 @@ public void ListSamlOptions(ProviderTestConfig config) var customOptions = new ListProviderConfigsOptions() { PageSize = 3, - PageToken = "custom-token", + PageToken = "custom+token=1&2#3", }; var pagedEnumerable = auth.ListSamlProviderConfigsAsync(customOptions); @@ -620,7 +620,7 @@ public void ListSamlOptions(ProviderTestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest( - "inboundSamlConfigs?pageSize=3&pageToken=custom-token", handler.Requests[0]); + "inboundSamlConfigs?pageSize=3&pageToken=custom%2Btoken%3D1%262%233", handler.Requests[0]); config.AssertRequest( "inboundSamlConfigs?pageSize=3&pageToken=token", handler.Requests[1]); } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs index ecd5526d..2d3023cf 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Users/FirebaseUserManagerTest.cs @@ -521,7 +521,7 @@ public void ListUsersCustomOptions(TestConfig config) var customOptions = new ListUsersOptions() { PageSize = 3, - PageToken = "custom-token", + PageToken = "custom+token=1&2#3", }; var pagedEnumerable = auth.ListUsersAsync(customOptions); @@ -539,7 +539,8 @@ public void ListUsersCustomOptions(TestConfig config) Assert.Equal(2, handler.Requests.Count); config.AssertRequest( - "accounts:batchGet?maxResults=3&nextPageToken=custom-token", handler.Requests[0]); + "accounts:batchGet?maxResults=3&nextPageToken=custom%2Btoken%3D1%262%233", + handler.Requests[0]); config.AssertRequest( "accounts:batchGet?maxResults=3&nextPageToken=token", handler.Requests[1]); } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Util/HttpUtilsTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Util/HttpUtilsTest.cs new file mode 100644 index 00000000..80779548 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Util/HttpUtilsTest.cs @@ -0,0 +1,66 @@ +// Copyright 2026, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using Xunit; + +namespace FirebaseAdmin.Util.Tests +{ + public class HttpUtilsTest + { + [Fact] + public void EncodeQueryParamsNullOrEmpty() + { + Assert.Equal(string.Empty, HttpUtils.EncodeQueryParams(null)); + Assert.Equal(string.Empty, HttpUtils.EncodeQueryParams(new Dictionary())); + } + + [Fact] + public void EncodeQueryParamsEscapesSpecialCharacters() + { + var queryParams = new Dictionary() + { + { "pageToken", "token+1=2&3#4/5" }, + { "pageSize", 100 }, + { "key with spaces", null }, + }; + + Assert.Equal( + "?key%20with%20spaces=&pageSize=100&pageToken=token%2B1%3D2%263%234%2F5", + HttpUtils.EncodeQueryParams(queryParams)); + } + + [Fact] + public void EncodeQueryParamsUsesInvariantCulture() + { + var originalCulture = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo("fr-FR"); + var queryParams = new Dictionary() + { + { "ratio", 1.5 }, + }; + + Assert.Equal("?ratio=1.5", HttpUtils.EncodeQueryParams(queryParams)); + } + finally + { + Thread.CurrentThread.CurrentCulture = originalCulture; + } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs b/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs index d946deba..9c144b41 100644 --- a/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs +++ b/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs @@ -14,6 +14,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.Http; using Google.Apis.Json; @@ -36,7 +37,13 @@ internal static string EncodeQueryParams(IDictionary queryParams if (queryParams != null && queryParams.Count > 0) { var list = queryParams.OrderBy(kvp => kvp.Key) - .Select(kvp => $"{kvp.Key}={kvp.Value}"); + .Select(kvp => + { + var key = Uri.EscapeDataString(kvp.Key); + var value = Uri.EscapeDataString( + Convert.ToString(kvp.Value, CultureInfo.InvariantCulture)); + return $"{key}={value}"; + }); queryString = "?" + string.Join("&", list); }