From 793d2632899c2022511dfc15e30725ef69073973 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Mon, 29 Jun 2026 12:24:52 +0200 Subject: [PATCH 01/16] fix: escape OpenAPI path in Refit verb attribute --- src/Refitter.Core/Generation/InterfaceGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Refitter.Core/Generation/InterfaceGenerator.cs b/src/Refitter.Core/Generation/InterfaceGenerator.cs index 30c95a5d..d772b522 100644 --- a/src/Refitter.Core/Generation/InterfaceGenerator.cs +++ b/src/Refitter.Core/Generation/InterfaceGenerator.cs @@ -237,7 +237,7 @@ private IEnumerable GenerateMultipleInterface( code.AppendLine($"{Separator}{Separator}{attribute}"); } - code.AppendLine($"{Separator}{Separator}[{verb}(\"{op.Path}\")]") + code.AppendLine($"{Separator}{Separator}[{verb}(\"{ParameterShared.EscapeString(op.Path)}\")]") .AppendLine($"{Separator}{Separator}{returnType} {methodName}({parametersString});") .AppendLine(); @@ -263,7 +263,7 @@ private IEnumerable GenerateMultipleInterface( ", ", parameters.Where(parameter => !parameter.Contains("?"))); - code.AppendLine($"{Separator}{Separator}[{verb}(\"{op.Path}\")]") + code.AppendLine($"{Separator}{Separator}[{verb}(\"{ParameterShared.EscapeString(op.Path)}\")]") .AppendLine($"{Separator}{Separator}{returnType} {methodName}({nonOptionalParametersString});") .AppendLine(); } From 63a19b03699a9c267d6fab5f758ec26484db83a1 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Mon, 29 Jun 2026 12:26:09 +0200 Subject: [PATCH 02/16] fix: escape header parameter and security scheme names --- .../ParameterExtraction/HeaderParameterExtractor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Refitter.Core/ParameterExtraction/HeaderParameterExtractor.cs b/src/Refitter.Core/ParameterExtraction/HeaderParameterExtractor.cs index 228f87de..57570fbf 100644 --- a/src/Refitter.Core/ParameterExtraction/HeaderParameterExtractor.cs +++ b/src/Refitter.Core/ParameterExtraction/HeaderParameterExtractor.cs @@ -27,7 +27,7 @@ public IEnumerable Extract( .Select(p => { var variableName = ParameterShared.GetVariableName(p); - return $"{ParameterShared.JoinAttributes($"Header(\"{p.Name}\")")}{ParameterShared.GetParameterType(p, settings)} {variableName}"; + return $"{ParameterShared.JoinAttributes($"Header(\"{ParameterShared.EscapeString(p.Name)}\")")}{ParameterShared.GetParameterType(p, settings)} {variableName}"; }) .ToList(); } @@ -47,7 +47,7 @@ public IEnumerable Extract( && securityScheme.In == OpenApiSecurityApiKeyLocation.Header && !operationModel.Parameters.Any(p => p.Kind == OpenApiParameterKind.Header && p.IsHeader && p.Name == securityScheme.Name)) { - headerParameters.Add($"[Header(\"{securityScheme.Name}\")] string {ParameterShared.ReplaceUnsafeCharacters(securityScheme.Name)}"); + headerParameters.Add($"[Header(\"{ParameterShared.EscapeString(securityScheme.Name)}\")] string {ParameterShared.ReplaceUnsafeCharacters(securityScheme.Name)}"); } else if (securityScheme is { Type: OpenApiSecuritySchemeType.Http } && string.Equals(securityScheme.Scheme, "bearer", StringComparison.OrdinalIgnoreCase)) From ab4d8e439d6f0ee82ad49ee15323978d70fe5a67 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Mon, 29 Jun 2026 12:26:09 +0200 Subject: [PATCH 03/16] fix: escape spec-derived media types in Headers attribute --- src/Refitter.Core/Generation/MethodAttributeGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Refitter.Core/Generation/MethodAttributeGenerator.cs b/src/Refitter.Core/Generation/MethodAttributeGenerator.cs index 76094ae7..0eb949a0 100644 --- a/src/Refitter.Core/Generation/MethodAttributeGenerator.cs +++ b/src/Refitter.Core/Generation/MethodAttributeGenerator.cs @@ -41,7 +41,7 @@ public string[] Generate(OpenApiOperation operation, CSharpOperationModel operat if (uniqueContentTypes.Any()) { - headers.Add($"\"Accept: {string.Join(", ", uniqueContentTypes)}\""); + headers.Add($"\"Accept: {string.Join(", ", uniqueContentTypes.Select(ParameterShared.EscapeString))}\""); } } @@ -54,7 +54,7 @@ public string[] Generate(OpenApiOperation operation, CSharpOperationModel operat if (!string.IsNullOrWhiteSpace(contentType) && !operationModel.Consumes.Contains("multipart/form-data")) { - headers.Add($"\"Content-Type: {contentType}\""); + headers.Add($"\"Content-Type: {ParameterShared.EscapeString(contentType)}\""); } } From f2ed3dca4fbcced5147a4b060c4b8495f2c4fa10 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Mon, 29 Jun 2026 12:28:15 +0200 Subject: [PATCH 04/16] feat: reject paths/headers with breakout chars in validation --- .../Validation/AttributeStringValidator.cs | 52 +++++++++++++++++++ .../Validation/OpenApiValidator.cs | 2 + 2 files changed, 54 insertions(+) create mode 100644 src/Refitter.Core/Validation/AttributeStringValidator.cs diff --git a/src/Refitter.Core/Validation/AttributeStringValidator.cs b/src/Refitter.Core/Validation/AttributeStringValidator.cs new file mode 100644 index 00000000..4230ac95 --- /dev/null +++ b/src/Refitter.Core/Validation/AttributeStringValidator.cs @@ -0,0 +1,52 @@ +using System.Linq; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; + +namespace Refitter.Core.Validation; + +/// +/// Rejects OpenAPI paths and header names containing characters that could break out of the +/// generated Refit attribute string literals (e.g. quotes, backslashes, newlines). Generated +/// code escapes these characters, but rejecting them gives a clear error unless validation is +/// skipped. See GHSA-3fhm-p725-h3g3. +/// +internal static class AttributeStringValidator +{ + internal static bool ContainsUnsafeCharacters(string? value) => + value != null && value.Any(c => c is '"' or '\\' || char.IsControl(c)); + + internal static void Validate(OpenApiDocument? document, OpenApiDiagnostic diagnostic) + { + if (document?.Paths == null) + return; + + foreach (var path in document.Paths) + { + if (ContainsUnsafeCharacters(path.Key)) + { + diagnostic.Errors.Add(new OpenApiError( + path.Key, + $"Path '{path.Key}' contains illegal characters (quotes, backslashes, or control characters) and is rejected to prevent code injection into Refit attributes. Use --skip-validation to bypass.")); + } + + if (path.Value?.Operations == null) + continue; + + foreach (var operation in path.Value.Operations.Values) + { + if (operation?.Parameters == null) + continue; + + foreach (var parameter in operation.Parameters) + { + if (parameter.In == ParameterLocation.Header && ContainsUnsafeCharacters(parameter.Name)) + { + diagnostic.Errors.Add(new OpenApiError( + parameter.Name ?? string.Empty, + $"Header parameter name '{parameter.Name}' contains illegal characters and is rejected to prevent code injection into Refit attributes. Use --skip-validation to bypass.")); + } + } + } + } + } +} diff --git a/src/Refitter.Core/Validation/OpenApiValidator.cs b/src/Refitter.Core/Validation/OpenApiValidator.cs index efc01650..5519ca42 100644 --- a/src/Refitter.Core/Validation/OpenApiValidator.cs +++ b/src/Refitter.Core/Validation/OpenApiValidator.cs @@ -26,6 +26,8 @@ public static async Task Validate( var walker = new OpenApiWalker(statsVisitor); walker.Walk(result.OpenApiDocument); + AttributeStringValidator.Validate(result.OpenApiDocument, result.OpenApiDiagnostic); + return new( result.OpenApiDiagnostic, statsVisitor); From ced34262ab547851f82828fa38c6838c4b521bc5 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Mon, 29 Jun 2026 12:36:00 +0200 Subject: [PATCH 05/16] test: add regression tests for attribute injection (GHSA-3fhm-p725-h3g3) --- .../SecurityAttributeInjectionTests.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs diff --git a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs new file mode 100644 index 00000000..37ca9d1c --- /dev/null +++ b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using Refitter.Core; +using Refitter.Core.Validation; +using Refitter.Tests.Build; +using Refitter.Tests.TestUtilities; +using TUnit.Core; + +namespace Refitter.Tests.Scenarios; + +// Regression tests for GHSA-3fhm-p725-h3g3: an OpenAPI path / header name that closes the +// Refit attribute string and injects a [ModuleInitializer] file class must not break out of +// the generated code, and must be rejected by validation. +public class SecurityAttributeInjectionTests +{ + private const string MaliciousPath = + @"/p"")] Task Dummy(); } } file class Evil { [System.Runtime.CompilerServices.ModuleInitializer] internal static void Init(){ } } namespace N2 { public partial interface I2 { [Get(""/q"; + + private const string PathInjectionSpec = @" +openapi: 3.0.0 +info: + title: Injection + version: 1.0.0 +paths: + '" + MaliciousPath + @"': + get: + operationId: GetU + responses: + '200': + description: ok +"; + + private const string HeaderInjectionSpec = @" +openapi: 3.0.0 +info: + title: Injection + version: 1.0.0 +paths: + /api: + get: + operationId: GetU + parameters: + - name: 'X"")] string a); } file class H {} public partial interface J { [Header(""Y' + in: header + schema: + type: string + responses: + '200': + description: ok +"; + + [Test] + public async Task Path_Injection_Does_Not_Break_Out_Of_Attribute() + { + string generatedCode = await GenerateCode(PathInjectionSpec); + generatedCode.Should().Contain("\\\""); + generatedCode.Should().NotContain("[Get(\"/q\")]"); + } + + [Category("Integration")] + [Test] + public async Task Generated_Code_With_Path_Injection_Is_Inert_And_Compiles() + { + string generatedCode = await GenerateCode(PathInjectionSpec); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + [Test] + public async Task Header_Injection_Does_Not_Break_Out_Of_Attribute() + { + string generatedCode = await GenerateCode(HeaderInjectionSpec); + generatedCode.Should().Contain("\\\""); + generatedCode.Should().NotContain("[Header(\"Y\")]"); + } + + [Category("Integration")] + [Test] + public async Task Generated_Code_With_Header_Injection_Is_Inert_And_Compiles() + { + string generatedCode = await GenerateCode(HeaderInjectionSpec); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + [Test] + public async Task Validation_Rejects_Path_With_Breakout_Characters() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(PathInjectionSpec); + try + { + var result = await OpenApiValidator.Validate(swaggerFile); + result.IsValid.Should().BeFalse(); + } + finally + { + CleanUp(swaggerFile); + } + } + + private static async Task GenerateCode(string spec) + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(spec); + try + { + var settings = new RefitGeneratorSettings { OpenApiPath = swaggerFile }; + var generator = await RefitGenerator.CreateAsync(settings); + return generator.Generate(); + } + finally + { + CleanUp(swaggerFile); + } + } + + private static void CleanUp(string swaggerFile) + { + if (File.Exists(swaggerFile)) + File.Delete(swaggerFile); + var directory = Path.GetDirectoryName(swaggerFile); + if (directory != null && Directory.Exists(directory)) + Directory.Delete(directory, true); + } +} From 465350cacd484b434ce8b2194a40b22452629d77 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:15:25 +0000 Subject: [PATCH 06/16] fix: apply CodeRabbit auto-fixes Fixed 3 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit --- .../Validation/AttributeStringValidator.cs | 22 +- .../Validation/OpenApiValidator.cs | 2 + .../SecurityAttributeInjectionTests.cs | 204 +++++++++++++++++- 3 files changed, 226 insertions(+), 2 deletions(-) diff --git a/src/Refitter.Core/Validation/AttributeStringValidator.cs b/src/Refitter.Core/Validation/AttributeStringValidator.cs index 4230ac95..6a3720bc 100644 --- a/src/Refitter.Core/Validation/AttributeStringValidator.cs +++ b/src/Refitter.Core/Validation/AttributeStringValidator.cs @@ -1,5 +1,6 @@ using System.Linq; using Microsoft.OpenApi; +using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Reader; namespace Refitter.Core.Validation; @@ -17,7 +18,26 @@ internal static bool ContainsUnsafeCharacters(string? value) => internal static void Validate(OpenApiDocument? document, OpenApiDiagnostic diagnostic) { - if (document?.Paths == null) + if (document == null) + return; + + // Validate security scheme names for API-key schemes with header location + if (document.Components?.SecuritySchemes != null) + { + foreach (var securityScheme in document.Components.SecuritySchemes) + { + if (securityScheme.Value?.Type == SecuritySchemeType.ApiKey + && securityScheme.Value.In == ParameterLocation.Header + && ContainsUnsafeCharacters(securityScheme.Value.Name)) + { + diagnostic.Errors.Add(new OpenApiError( + securityScheme.Key, + $"Security scheme '{securityScheme.Key}' has header name '{securityScheme.Value.Name}' containing illegal characters and is rejected to prevent code injection into Refit attributes. Use --skip-validation to bypass.")); + } + } + } + + if (document.Paths == null) return; foreach (var path in document.Paths) diff --git a/src/Refitter.Core/Validation/OpenApiValidator.cs b/src/Refitter.Core/Validation/OpenApiValidator.cs index 5519ca42..39f1f5fa 100644 --- a/src/Refitter.Core/Validation/OpenApiValidator.cs +++ b/src/Refitter.Core/Validation/OpenApiValidator.cs @@ -1,3 +1,5 @@ +using System.Threading; +using System.Threading.Tasks; using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; diff --git a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs index 37ca9d1c..a33dbee3 100644 --- a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs +++ b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs @@ -15,8 +15,25 @@ public class SecurityAttributeInjectionTests private const string MaliciousPath = @"/p"")] Task Dummy(); } } file class Evil { [System.Runtime.CompilerServices.ModuleInitializer] internal static void Init(){ } } namespace N2 { public partial interface I2 { [Get(""/q"; + private const string MaliciousHeaderName = + @"X"")] string a); } file class H {} public partial interface J { [Header(""Y"; + private const string PathInjectionSpec = @" openapi: 3.0.0 +info: + title: Injection + version: 1.0.0 +paths: + '" + MaliciousPath + @"': + get: + operationId: GetU + responses: + '200': + description: ok +"; + + private const string PathInjectionSpecV2 = @" +swagger: '2.0' info: title: Injection version: 1.0.0 @@ -39,7 +56,7 @@ public class SecurityAttributeInjectionTests get: operationId: GetU parameters: - - name: 'X"")] string a); } file class H {} public partial interface J { [Header(""Y' + - name: '" + MaliciousHeaderName + @"' in: header schema: type: string @@ -48,6 +65,67 @@ public class SecurityAttributeInjectionTests description: ok "; + private const string HeaderInjectionSpecV2 = @" +swagger: '2.0' +info: + title: Injection + version: 1.0.0 +paths: + /api: + get: + operationId: GetU + parameters: + - name: '" + MaliciousHeaderName + @"' + in: header + type: string + responses: + '200': + description: ok +"; + + private const string SecuritySchemeHeaderInjectionSpec = @" +openapi: 3.0.0 +info: + title: Injection + version: 1.0.0 +paths: + /api: + get: + operationId: GetU + security: + - apiKey: [] + responses: + '200': + description: ok +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: '" + MaliciousHeaderName + @"' +"; + + private const string SecuritySchemeHeaderInjectionSpecV2 = @" +swagger: '2.0' +info: + title: Injection + version: 1.0.0 +paths: + /api: + get: + operationId: GetU + security: + - apiKey: [] + responses: + '200': + description: ok +securityDefinitions: + apiKey: + type: apiKey + in: header + name: '" + MaliciousHeaderName + @"' +"; + [Test] public async Task Path_Injection_Does_Not_Break_Out_Of_Attribute() { @@ -95,6 +173,130 @@ public async Task Validation_Rejects_Path_With_Breakout_Characters() } } + [Test] + public async Task Path_Injection_Does_Not_Break_Out_Of_Attribute_V2() + { + string generatedCode = await GenerateCode(PathInjectionSpecV2); + generatedCode.Should().Contain("\\\""); + generatedCode.Should().NotContain("[Get(\"/q\")]"); + } + + [Category("Integration")] + [Test] + public async Task Generated_Code_With_Path_Injection_Is_Inert_And_Compiles_V2() + { + string generatedCode = await GenerateCode(PathInjectionSpecV2); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + [Test] + public async Task Header_Injection_Does_Not_Break_Out_Of_Attribute_V2() + { + string generatedCode = await GenerateCode(HeaderInjectionSpecV2); + generatedCode.Should().Contain("\\\""); + generatedCode.Should().NotContain("[Header(\"Y\")]"); + } + + [Category("Integration")] + [Test] + public async Task Generated_Code_With_Header_Injection_Is_Inert_And_Compiles_V2() + { + string generatedCode = await GenerateCode(HeaderInjectionSpecV2); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + [Test] + public async Task Validation_Rejects_Path_With_Breakout_Characters_V2() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(PathInjectionSpecV2); + try + { + var result = await OpenApiValidator.Validate(swaggerFile); + result.IsValid.Should().BeFalse(); + } + finally + { + CleanUp(swaggerFile); + } + } + + [Test] + public async Task Validation_Rejects_Header_With_Breakout_Characters_V2() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(HeaderInjectionSpecV2); + try + { + var result = await OpenApiValidator.Validate(swaggerFile); + result.IsValid.Should().BeFalse(); + } + finally + { + CleanUp(swaggerFile); + } + } + + [Test] + public async Task SecurityScheme_Header_Injection_Does_Not_Break_Out_Of_Attribute() + { + string generatedCode = await GenerateCode(SecuritySchemeHeaderInjectionSpec); + generatedCode.Should().Contain("\\\""); + generatedCode.Should().NotContain("[Header(\"Y\")]"); + } + + [Category("Integration")] + [Test] + public async Task Generated_Code_With_SecurityScheme_Header_Injection_Is_Inert_And_Compiles() + { + string generatedCode = await GenerateCode(SecuritySchemeHeaderInjectionSpec); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + [Test] + public async Task Validation_Rejects_SecurityScheme_Header_With_Breakout_Characters() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(SecuritySchemeHeaderInjectionSpec); + try + { + var result = await OpenApiValidator.Validate(swaggerFile); + result.IsValid.Should().BeFalse(); + } + finally + { + CleanUp(swaggerFile); + } + } + + [Test] + public async Task SecurityScheme_Header_Injection_Does_Not_Break_Out_Of_Attribute_V2() + { + string generatedCode = await GenerateCode(SecuritySchemeHeaderInjectionSpecV2); + generatedCode.Should().Contain("\\\""); + generatedCode.Should().NotContain("[Header(\"Y\")]"); + } + + [Category("Integration")] + [Test] + public async Task Generated_Code_With_SecurityScheme_Header_Injection_Is_Inert_And_Compiles_V2() + { + string generatedCode = await GenerateCode(SecuritySchemeHeaderInjectionSpecV2); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + [Test] + public async Task Validation_Rejects_SecurityScheme_Header_With_Breakout_Characters_V2() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(SecuritySchemeHeaderInjectionSpecV2); + try + { + var result = await OpenApiValidator.Validate(swaggerFile); + result.IsValid.Should().BeFalse(); + } + finally + { + CleanUp(swaggerFile); + } + } + private static async Task GenerateCode(string spec) { var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(spec); From a06db08ac5e923970cea6d7e6e0529ad68507aa7 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:28:37 +0000 Subject: [PATCH 07/16] fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit --- .../Validation/AttributeStringValidator.cs | 1 - .../Scenarios/SecurityAttributeInjectionTests.cs | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Refitter.Core/Validation/AttributeStringValidator.cs b/src/Refitter.Core/Validation/AttributeStringValidator.cs index 6a3720bc..1df820ab 100644 --- a/src/Refitter.Core/Validation/AttributeStringValidator.cs +++ b/src/Refitter.Core/Validation/AttributeStringValidator.cs @@ -1,5 +1,4 @@ using System.Linq; -using Microsoft.OpenApi; using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Reader; diff --git a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs index a33dbee3..328d2a84 100644 --- a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs +++ b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs @@ -220,6 +220,21 @@ public async Task Validation_Rejects_Path_With_Breakout_Characters_V2() } } + [Test] + public async Task Validation_Rejects_Header_With_Breakout_Characters() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(HeaderInjectionSpec); + try + { + var result = await OpenApiValidator.Validate(swaggerFile); + result.IsValid.Should().BeFalse(); + } + finally + { + CleanUp(swaggerFile); + } + } + [Test] public async Task Validation_Rejects_Header_With_Breakout_Characters_V2() { From 673bc99c466b79044a793a6d44a58bfacc28fbc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:13:22 +0000 Subject: [PATCH 08/16] fix: restore OpenAPI namespace import --- src/Refitter.Core/Validation/AttributeStringValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Refitter.Core/Validation/AttributeStringValidator.cs b/src/Refitter.Core/Validation/AttributeStringValidator.cs index 1df820ab..d42acf50 100644 --- a/src/Refitter.Core/Validation/AttributeStringValidator.cs +++ b/src/Refitter.Core/Validation/AttributeStringValidator.cs @@ -1,5 +1,5 @@ using System.Linq; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; namespace Refitter.Core.Validation; From 5952ac85f6b6e30164ca717bd9320a0bb7ab496b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:23:04 +0000 Subject: [PATCH 09/16] test: align security scheme injection checks --- .../SecurityAttributeInjectionTests.cs | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs index 328d2a84..1f35c71a 100644 --- a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs +++ b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs @@ -253,8 +253,10 @@ public async Task Validation_Rejects_Header_With_Breakout_Characters_V2() [Test] public async Task SecurityScheme_Header_Injection_Does_Not_Break_Out_Of_Attribute() { - string generatedCode = await GenerateCode(SecuritySchemeHeaderInjectionSpec); - generatedCode.Should().Contain("\\\""); + string generatedCode = await GenerateCode( + SecuritySchemeHeaderInjectionSpec, + AuthenticationHeaderStyle.Parameter); + generatedCode.Should().Contain("[Get(\"/api\")]"); generatedCode.Should().NotContain("[Header(\"Y\")]"); } @@ -262,7 +264,9 @@ public async Task SecurityScheme_Header_Injection_Does_Not_Break_Out_Of_Attribut [Test] public async Task Generated_Code_With_SecurityScheme_Header_Injection_Is_Inert_And_Compiles() { - string generatedCode = await GenerateCode(SecuritySchemeHeaderInjectionSpec); + string generatedCode = await GenerateCode( + SecuritySchemeHeaderInjectionSpec, + AuthenticationHeaderStyle.Parameter); BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); } @@ -284,8 +288,10 @@ public async Task Validation_Rejects_SecurityScheme_Header_With_Breakout_Charact [Test] public async Task SecurityScheme_Header_Injection_Does_Not_Break_Out_Of_Attribute_V2() { - string generatedCode = await GenerateCode(SecuritySchemeHeaderInjectionSpecV2); - generatedCode.Should().Contain("\\\""); + string generatedCode = await GenerateCode( + SecuritySchemeHeaderInjectionSpecV2, + AuthenticationHeaderStyle.Parameter); + generatedCode.Should().Contain("[Get(\"/api\")]"); generatedCode.Should().NotContain("[Header(\"Y\")]"); } @@ -293,7 +299,9 @@ public async Task SecurityScheme_Header_Injection_Does_Not_Break_Out_Of_Attribut [Test] public async Task Generated_Code_With_SecurityScheme_Header_Injection_Is_Inert_And_Compiles_V2() { - string generatedCode = await GenerateCode(SecuritySchemeHeaderInjectionSpecV2); + string generatedCode = await GenerateCode( + SecuritySchemeHeaderInjectionSpecV2, + AuthenticationHeaderStyle.Parameter); BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); } @@ -312,12 +320,18 @@ public async Task Validation_Rejects_SecurityScheme_Header_With_Breakout_Charact } } - private static async Task GenerateCode(string spec) + private static async Task GenerateCode( + string spec, + AuthenticationHeaderStyle authenticationHeaderStyle = AuthenticationHeaderStyle.None) { var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(spec); try { - var settings = new RefitGeneratorSettings { OpenApiPath = swaggerFile }; + var settings = new RefitGeneratorSettings + { + OpenApiPath = swaggerFile, + AuthenticationHeaderStyle = authenticationHeaderStyle + }; var generator = await RefitGenerator.CreateAsync(settings); return generator.Generate(); } From 7a7f7d11be22cd0488de47f1ea8c4d93ac9bac4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:31:20 +0000 Subject: [PATCH 10/16] chore: finalize CI fix verification --- .../SwaggerPetstoreMultipleInterfaces.g.cs | 25 ++++++++++++++++--- ...toreSingleInterfaceWithHttpResilience.g.cs | 3 ++- ...aggerPetstoreSingleInterfaceWithPolly.g.cs | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreMultipleInterfaces.g.cs b/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreMultipleInterfaces.g.cs index 4e0d2a83..69ad732c 100644 --- a/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreMultipleInterfaces.g.cs +++ b/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreMultipleInterfaces.g.cs @@ -8,7 +8,6 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using System.Net.Http; using Refitter.Tests.AdditionalFiles.SingeInterface; @@ -512,8 +511,9 @@ public partial interface IDeleteUserEndpoint namespace Refitter.Tests.AdditionalFiles.ByEndpoint { using System; - using Microsoft.Extensions.DependencyInjection; - using Refit; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Refit; /// /// Extension methods for configuring Refit clients in the service collection. @@ -536,6 +536,7 @@ public static IServiceCollection ConfigureRefitClients( { var clientBuilderIUpdatePetEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -544,6 +545,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIAddPetEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -552,6 +554,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIFindPetsByStatusEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -560,6 +563,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIFindPetsByTagsEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -568,6 +572,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIGetPetByIdEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -576,6 +581,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIUpdatePetWithFormEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -584,6 +590,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIDeletePetEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -592,6 +599,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIUploadFileEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -600,6 +608,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIGetInventoryEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -608,6 +617,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIPlaceOrderEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -616,6 +626,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIGetOrderByIdEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -624,6 +635,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIDeleteOrderEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -632,6 +644,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderICreateUserEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -640,6 +653,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderICreateUsersWithListInputEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -648,6 +662,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderILoginUserEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -656,6 +671,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderILogoutUserEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -664,6 +680,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIGetUserByNameEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -672,6 +689,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIUpdateUserEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); @@ -680,6 +698,7 @@ public static IServiceCollection ConfigureRefitClients( var clientBuilderIDeleteUserEndpoint = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = baseUrl) .AddHttpMessageHandler() .AddHttpMessageHandler(); diff --git a/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreSingleInterfaceWithHttpResilience.g.cs b/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreSingleInterfaceWithHttpResilience.g.cs index 2732b463..858fc7db 100644 --- a/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreSingleInterfaceWithHttpResilience.g.cs +++ b/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreSingleInterfaceWithHttpResilience.g.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using System.Threading.Tasks; -using System.Net.Http; #nullable enable annotations @@ -743,6 +742,7 @@ public FileParameter(System.IO.Stream data, string fileName, string contentType) namespace Refitter.Tests.AdditionalFiles.SingeInterfaceWithHttpResilience { using System; + using System.Net.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; using Refit; @@ -766,6 +766,7 @@ public static IServiceCollection ConfigureRefitClients( { var clientBuilderISwaggerPetstoreInterfaceWithHttpResilience = services .AddRefitClient(settings) + .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://petstore3.swagger.io/api/v3")); clientBuilderISwaggerPetstoreInterfaceWithHttpResilience diff --git a/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreSingleInterfaceWithPolly.g.cs b/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreSingleInterfaceWithPolly.g.cs index e4765649..dc01821c 100644 --- a/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreSingleInterfaceWithPolly.g.cs +++ b/src/Refitter.SourceGenerator.Tests/AdditionalFiles/Generated/SwaggerPetstoreSingleInterfaceWithPolly.g.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using System.Threading.Tasks; -using System.Net.Http; #nullable enable annotations @@ -453,6 +452,7 @@ public FileParameter(System.IO.Stream data, string fileName, string contentType) namespace Refitter.Tests.AdditionalFiles.SingeInterface { using System; + using System.Net.Http; using Microsoft.Extensions.DependencyInjection; using Polly; using Polly.Contrib.WaitAndRetry; From 83d4d69c48d2f12debf9489b9844a5c6f56f7705 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Mon, 29 Jun 2026 17:35:25 +0200 Subject: [PATCH 11/16] test: attribute string validator --- .../OpenApi/AttributeStringValidatorTests.cs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs diff --git a/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs b/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs new file mode 100644 index 00000000..569fdb84 --- /dev/null +++ b/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Reader; +using Refitter.Core.Validation; +using System.Net.Http; + +namespace Refitter.Tests.OpenApi; + +public class AttributeStringValidatorTests +{ + [Test] + public void ContainsUnsafeCharacters_Should_Return_False_For_Null_And_Safe_Values() + { + AttributeStringValidator.ContainsUnsafeCharacters(null).Should().BeFalse(); + AttributeStringValidator.ContainsUnsafeCharacters("X-Api-Key").Should().BeFalse(); + } + + [Test] + public void ContainsUnsafeCharacters_Should_Return_True_For_Unsafe_Values() + { + AttributeStringValidator.ContainsUnsafeCharacters("X\"Api").Should().BeTrue(); + AttributeStringValidator.ContainsUnsafeCharacters("X\\Api").Should().BeTrue(); + AttributeStringValidator.ContainsUnsafeCharacters("X\nApi").Should().BeTrue(); + } + + [Test] + public void Validate_Should_Return_When_Document_Is_Null() + { + OpenApiDiagnostic diagnostic = new(); + + AttributeStringValidator.Validate(null, diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + } + + [Test] + public void Validate_Should_Validate_SecurityScheme_And_Return_When_Paths_Are_Null() + { + OpenApiDiagnostic diagnostic = new(); + OpenApiDocument document = new() + { + Components = new OpenApiComponents + { + SecuritySchemes = new Dictionary + { + ["nullScheme"] = null!, + ["unsafeHeader"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Name = "X\"Api" + }, + ["safeHeader"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Name = "X-Api-Key" + }, + ["unsafeQuery"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Query, + Name = "X\"Api" + } + } + }, + Paths = null! + }; + + AttributeStringValidator.Validate(document, diagnostic); + + diagnostic.Errors.Should().HaveCount(1); + diagnostic.Errors[0].Pointer.Should().Be("unsafeHeader"); + diagnostic.Errors[0].Message.Should().Contain("Security scheme 'unsafeHeader'"); + } + + [Test] + public void Validate_Should_Report_Path_And_Header_Errors_And_Skip_Null_Branches() + { + OpenApiDiagnostic diagnostic = new(); + OpenApiDocument document = new() + { + Paths = new OpenApiPaths + { + ["/nullpath"] = null!, + ["unsafe\"path"] = new OpenApiPathItem + { + Operations = null! + }, + ["/safe"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Get] = new OpenApiOperation + { + Parameters = null! + }, + [HttpMethod.Post] = null!, + [HttpMethod.Put] = new OpenApiOperation + { + Parameters = new List + { + new OpenApiParameter + { + In = ParameterLocation.Header, + Name = "X\"Bad" + }, + new OpenApiParameter + { + In = ParameterLocation.Header, + Name = "X-Good" + }, + new OpenApiParameter + { + In = ParameterLocation.Query, + Name = "X\"Ignored" + } + } + } + } + } + } + }; + + AttributeStringValidator.Validate(document, diagnostic); + + diagnostic.Errors.Should().HaveCount(2); + diagnostic.Errors.Select(x => x.Pointer).Should().Contain(new[] { "unsafe\"path", "X\"Bad" }); + } +} From ecf4760cca50493df9f38329319416a8ae1fdf41 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Tue, 30 Jun 2026 13:44:53 +0200 Subject: [PATCH 12/16] feat: reject unsafe content-type keys in validation (GHSA-p32v-8v8j-j534) --- .../Validation/AttributeStringValidator.cs | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/Refitter.Core/Validation/AttributeStringValidator.cs b/src/Refitter.Core/Validation/AttributeStringValidator.cs index d42acf50..dddc6cba 100644 --- a/src/Refitter.Core/Validation/AttributeStringValidator.cs +++ b/src/Refitter.Core/Validation/AttributeStringValidator.cs @@ -39,6 +39,10 @@ internal static void Validate(OpenApiDocument? document, OpenApiDiagnostic diagn if (document.Paths == null) return; + // Accept/Content-Type headers are only emitted from content map keys for OpenAPI 3.0+, + // so only reject unsafe content-type keys for those documents to avoid Swagger 2.0 false positives. + var validateContentTypes = diagnostic.SpecificationVersion != OpenApiSpecVersion.OpenApi2_0; + foreach (var path in document.Paths) { if (ContainsUnsafeCharacters(path.Key)) @@ -53,19 +57,62 @@ internal static void Validate(OpenApiDocument? document, OpenApiDiagnostic diagn foreach (var operation in path.Value.Operations.Values) { - if (operation?.Parameters == null) + if (operation == null) continue; - foreach (var parameter in operation.Parameters) + if (operation.Parameters != null) { - if (parameter.In == ParameterLocation.Header && ContainsUnsafeCharacters(parameter.Name)) + foreach (var parameter in operation.Parameters) { - diagnostic.Errors.Add(new OpenApiError( - parameter.Name ?? string.Empty, - $"Header parameter name '{parameter.Name}' contains illegal characters and is rejected to prevent code injection into Refit attributes. Use --skip-validation to bypass.")); + if (parameter.In == ParameterLocation.Header && ContainsUnsafeCharacters(parameter.Name)) + { + diagnostic.Errors.Add(new OpenApiError( + parameter.Name ?? string.Empty, + $"Header parameter name '{parameter.Name}' contains illegal characters and is rejected to prevent code injection into Refit attributes. Use --skip-validation to bypass.")); + } } } + + if (validateContentTypes) + { + ValidateContentTypeKeys(operation, diagnostic); + } } } } + + private static void ValidateContentTypeKeys(OpenApiOperation operation, OpenApiDiagnostic diagnostic) + { + if (operation.RequestBody?.Content != null) + { + foreach (var contentType in operation.RequestBody.Content.Keys) + { + AddContentTypeErrorIfUnsafe(contentType, diagnostic); + } + } + + if (operation.Responses == null) + return; + + foreach (var response in operation.Responses.Values) + { + if (response?.Content == null) + continue; + + foreach (var contentType in response.Content.Keys) + { + AddContentTypeErrorIfUnsafe(contentType, diagnostic); + } + } + } + + private static void AddContentTypeErrorIfUnsafe(string contentType, OpenApiDiagnostic diagnostic) + { + if (ContainsUnsafeCharacters(contentType)) + { + diagnostic.Errors.Add(new OpenApiError( + contentType, + $"Content type '{contentType}' contains illegal characters and is rejected to prevent code injection into Refit attributes. Use --skip-validation to bypass.")); + } + } } From 150869d9b1b173ef4675cad35bbaa1b0e1d7bece Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Tue, 30 Jun 2026 13:49:21 +0200 Subject: [PATCH 13/16] test: add content-type injection regression tests (GHSA-p32v-8v8j-j534) --- .../SecurityAttributeInjectionTests.cs | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs index 1f35c71a..d6cc60ba 100644 --- a/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs +++ b/src/Refitter.Tests/Scenarios/SecurityAttributeInjectionTests.cs @@ -7,9 +7,10 @@ namespace Refitter.Tests.Scenarios; -// Regression tests for GHSA-3fhm-p725-h3g3: an OpenAPI path / header name that closes the -// Refit attribute string and injects a [ModuleInitializer] file class must not break out of -// the generated code, and must be rejected by validation. +// Regression tests for the Refit attribute-injection advisories. An OpenAPI path, header name, or +// content-type key that closes the Refit attribute string and injects a [ModuleInitializer] file +// class must not break out of the generated code, and must be rejected by validation. +// Covers GHSA-3fhm-p725-h3g3 (path), GHSA-58x9-vjvp-6mx8 (header name), GHSA-p32v-8v8j-j534 (content-type). public class SecurityAttributeInjectionTests { private const string MaliciousPath = @@ -18,6 +19,9 @@ public class SecurityAttributeInjectionTests private const string MaliciousHeaderName = @"X"")] string a); } file class H {} public partial interface J { [Header(""Y"; + private const string MaliciousContentType = + @"application/json"")] Task Dummy(); } } file class CtEvil { [System.Runtime.CompilerServices.ModuleInitializer] internal static void Init(){ } } namespace N3 { public partial interface I3 { [Get(""/ctq"; + private const string PathInjectionSpec = @" openapi: 3.0.0 info: @@ -126,6 +130,29 @@ public class SecurityAttributeInjectionTests name: '" + MaliciousHeaderName + @"' "; + private const string ContentTypeInjectionSpec = @" +openapi: 3.0.0 +info: + title: Injection + version: 1.0.0 +paths: + /api: + post: + operationId: PostU + requestBody: + content: + '" + MaliciousContentType + @"': + schema: + type: string + responses: + '200': + description: ok + content: + '" + MaliciousContentType + @"': + schema: + type: string +"; + [Test] public async Task Path_Injection_Does_Not_Break_Out_Of_Attribute() { @@ -320,6 +347,37 @@ public async Task Validation_Rejects_SecurityScheme_Header_With_Breakout_Charact } } + [Test] + public async Task ContentType_Injection_Does_Not_Break_Out_Of_Attribute() + { + string generatedCode = await GenerateCode(ContentTypeInjectionSpec); + generatedCode.Should().Contain("\\\""); + generatedCode.Should().NotContain("application/json\")]"); + } + + [Category("Integration")] + [Test] + public async Task Generated_Code_With_ContentType_Injection_Is_Inert_And_Compiles() + { + string generatedCode = await GenerateCode(ContentTypeInjectionSpec); + BuildHelper.BuildCSharp(generatedCode).Should().BeTrue(); + } + + [Test] + public async Task Validation_Rejects_ContentType_With_Breakout_Characters() + { + var swaggerFile = await SwaggerFileHelper.CreateSwaggerFile(ContentTypeInjectionSpec); + try + { + var result = await OpenApiValidator.Validate(swaggerFile); + result.IsValid.Should().BeFalse(); + } + finally + { + CleanUp(swaggerFile); + } + } + private static async Task GenerateCode( string spec, AuthenticationHeaderStyle authenticationHeaderStyle = AuthenticationHeaderStyle.None) From ec99260abd95d6ef8a72754bee367f6b854ef546 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Tue, 30 Jun 2026 13:52:09 +0200 Subject: [PATCH 14/16] test: cover content-type key validation for OpenAPI 3 and 2 --- .../OpenApi/AttributeStringValidatorTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs b/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs index 569fdb84..53299270 100644 --- a/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs +++ b/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs @@ -127,4 +127,63 @@ public void Validate_Should_Report_Path_And_Header_Errors_And_Skip_Null_Branches diagnostic.Errors.Should().HaveCount(2); diagnostic.Errors.Select(x => x.Pointer).Should().Contain(new[] { "unsafe\"path", "X\"Bad" }); } + + [Test] + public void Validate_Should_Report_ContentType_Errors_For_OpenApi3() + { + OpenApiDiagnostic diagnostic = new() { SpecificationVersion = OpenApiSpecVersion.OpenApi3_0 }; + OpenApiDocument document = BuildContentTypeDocument(); + + AttributeStringValidator.Validate(document, diagnostic); + + diagnostic.Errors.Should().HaveCount(2); + diagnostic.Errors.Select(x => x.Message).Should().OnlyContain(m => m.Contains("Content type")); + diagnostic.Errors.Select(x => x.Pointer).Should().Contain(new[] { "application/json\")] x", "text/plain\"" }); + } + + [Test] + public void Validate_Should_Not_Report_ContentType_Errors_For_OpenApi2() + { + OpenApiDiagnostic diagnostic = new() { SpecificationVersion = OpenApiSpecVersion.OpenApi2_0 }; + OpenApiDocument document = BuildContentTypeDocument(); + + AttributeStringValidator.Validate(document, diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + } + + private static OpenApiDocument BuildContentTypeDocument() => new() + { + Paths = new OpenApiPaths + { + ["/api"] = new OpenApiPathItem + { + Operations = new Dictionary + { + [HttpMethod.Post] = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + ["application/json"] = new OpenApiMediaType(), + ["application/json\")] x"] = new OpenApiMediaType() + } + }, + Responses = new OpenApiResponses + { + ["200"] = new OpenApiResponse + { + Content = new Dictionary + { + ["text/plain\""] = new OpenApiMediaType() + } + }, + ["204"] = null! + } + } + } + } + } + }; } From 854b098eb20e0dc02f3f30542735d684437ce594 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Tue, 30 Jun 2026 13:52:33 +0200 Subject: [PATCH 15/16] docs: reference all three injection advisories in validator --- src/Refitter.Core/Validation/AttributeStringValidator.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Refitter.Core/Validation/AttributeStringValidator.cs b/src/Refitter.Core/Validation/AttributeStringValidator.cs index dddc6cba..eeb73f75 100644 --- a/src/Refitter.Core/Validation/AttributeStringValidator.cs +++ b/src/Refitter.Core/Validation/AttributeStringValidator.cs @@ -5,10 +5,10 @@ namespace Refitter.Core.Validation; /// -/// Rejects OpenAPI paths and header names containing characters that could break out of the -/// generated Refit attribute string literals (e.g. quotes, backslashes, newlines). Generated -/// code escapes these characters, but rejecting them gives a clear error unless validation is -/// skipped. See GHSA-3fhm-p725-h3g3. +/// Rejects OpenAPI paths, header names, and content-type keys containing characters that could break +/// out of the generated Refit attribute string literals (e.g. quotes, backslashes, newlines). Generated +/// code escapes these characters, but rejecting them gives a clear error unless validation is skipped. +/// See GHSA-3fhm-p725-h3g3 (path), GHSA-58x9-vjvp-6mx8 (header name), GHSA-p32v-8v8j-j534 (content-type). /// internal static class AttributeStringValidator { From 83f3ad2e0791ba74725b9b81d47b2ec8729b48e1 Mon Sep 17 00:00:00 2001 From: Christian Helle Date: Tue, 30 Jun 2026 14:01:06 +0200 Subject: [PATCH 16/16] style: sort System import first in validator tests --- src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs b/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs index 53299270..9c828b25 100644 --- a/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs +++ b/src/Refitter.Tests/OpenApi/AttributeStringValidatorTests.cs @@ -1,8 +1,8 @@ +using System.Net.Http; using FluentAssertions; using Microsoft.OpenApi; using Microsoft.OpenApi.Reader; using Refitter.Core.Validation; -using System.Net.Http; namespace Refitter.Tests.OpenApi;