Skip to content

Commit 09ea38c

Browse files
Implement CORS policy configuration and testing; add CI workflow and project structure for tests
1 parent d1e3407 commit 09ea38c

14 files changed

Lines changed: 832 additions & 33 deletions

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
jobs:
8+
build-and-test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
- uses: actions/setup-dotnet@v4
14+
with:
15+
dotnet-version: 10.0.x
16+
17+
- name: Restore
18+
run: dotnet restore aspire-tunnel-proxy.sln
19+
20+
- name: Build
21+
run: dotnet build aspire-tunnel-proxy.sln --configuration Release --no-restore
22+
23+
- name: Test
24+
run: dotnet test aspire-tunnel-proxy.sln --configuration Release --no-build --verbosity normal

aspire-tunnel-proxy.sln

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "src\AppHost\AppH
99
EndProject
1010
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Proxy", "src\Proxy\Proxy.csproj", "{33BF67C5-C7DC-40B4-9517-E1D4F621F7D7}"
1111
EndProject
12+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
13+
EndProject
14+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Proxy.Tests", "tests\Proxy.Tests\Proxy.Tests.csproj", "{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}"
15+
EndProject
1216
Global
1317
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1418
Debug|Any CPU = Debug|Any CPU
@@ -43,12 +47,25 @@ Global
4347
{33BF67C5-C7DC-40B4-9517-E1D4F621F7D7}.Release|x64.Build.0 = Release|Any CPU
4448
{33BF67C5-C7DC-40B4-9517-E1D4F621F7D7}.Release|x86.ActiveCfg = Release|Any CPU
4549
{33BF67C5-C7DC-40B4-9517-E1D4F621F7D7}.Release|x86.Build.0 = Release|Any CPU
50+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
51+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
52+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Debug|x64.ActiveCfg = Debug|Any CPU
53+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Debug|x64.Build.0 = Debug|Any CPU
54+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Debug|x86.ActiveCfg = Debug|Any CPU
55+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Debug|x86.Build.0 = Debug|Any CPU
56+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
57+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Release|Any CPU.Build.0 = Release|Any CPU
58+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Release|x64.ActiveCfg = Release|Any CPU
59+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Release|x64.Build.0 = Release|Any CPU
60+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Release|x86.ActiveCfg = Release|Any CPU
61+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1}.Release|x86.Build.0 = Release|Any CPU
4662
EndGlobalSection
4763
GlobalSection(SolutionProperties) = preSolution
4864
HideSolutionNode = FALSE
4965
EndGlobalSection
5066
GlobalSection(NestedProjects) = preSolution
5167
{A0C58334-56A9-4365-8332-85B7AB609A02} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
5268
{33BF67C5-C7DC-40B4-9517-E1D4F621F7D7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
69+
{8A5676C2-92F0-4FB9-9E56-F055CC86A7F1} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
5370
EndGlobalSection
5471
EndGlobal
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using Microsoft.AspNetCore.Cors.Infrastructure;
2+
3+
namespace AspireTunnelProxy.Cors;
4+
5+
internal static class CorsPolicyConfigurator
6+
{
7+
public static void Configure(CorsOptions options, IConfiguration policiesSection)
8+
{
9+
var policies = policiesSection.GetChildren();
10+
foreach (var policySection in policies)
11+
{
12+
var name = policySection.Key;
13+
var origins = policySection.GetSection("AllowedOrigins").Get<string[]>() ?? [];
14+
var methods = policySection.GetSection("AllowedMethods").Get<string[]>() ?? [];
15+
var headers = policySection.GetSection("AllowedHeaders").Get<string[]>() ?? [];
16+
var exposed = policySection.GetSection("ExposedHeaders").Get<string[]>() ?? [];
17+
var allowCredentials = policySection.GetValue<bool>("AllowCredentials");
18+
var preflightMaxAgeSeconds = policySection.GetValue<int?>("PreflightMaxAgeSeconds");
19+
20+
if (allowCredentials && origins.Contains("*"))
21+
{
22+
throw new InvalidOperationException(
23+
$"CORS policy '{name}' has AllowCredentials=true with AllowedOrigins '*'. " +
24+
"Specify explicit origins when credentials are allowed.");
25+
}
26+
27+
options.AddPolicy(name, policy =>
28+
{
29+
if (origins is ["*"]) policy.AllowAnyOrigin(); else if (origins.Length > 0) policy.WithOrigins(origins);
30+
if (methods is ["*"]) policy.AllowAnyMethod(); else if (methods.Length > 0) policy.WithMethods(methods);
31+
if (headers is ["*"]) policy.AllowAnyHeader(); else if (headers.Length > 0) policy.WithHeaders(headers);
32+
if (exposed.Length > 0) policy.WithExposedHeaders(exposed);
33+
if (allowCredentials) policy.AllowCredentials();
34+
if (preflightMaxAgeSeconds is int seconds) policy.SetPreflightMaxAge(TimeSpan.FromSeconds(seconds));
35+
});
36+
}
37+
}
38+
}

src/Proxy/Program.cs

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using AspireTunnelProxy.Cors;
12
using AspireTunnelProxy.Transforms;
23

34
var builder = WebApplication.CreateBuilder(args);
@@ -7,40 +8,13 @@
78
.AddTransforms<InjectJsonFieldsTransformProvider>();
89

910
builder.Services.AddCors(options =>
10-
{
11-
var policies = builder.Configuration.GetSection("Cors:Policies").GetChildren();
12-
foreach (var policySection in policies)
13-
{
14-
var name = policySection.Key;
15-
var origins = policySection.GetSection("AllowedOrigins").Get<string[]>() ?? [];
16-
var methods = policySection.GetSection("AllowedMethods").Get<string[]>() ?? [];
17-
var headers = policySection.GetSection("AllowedHeaders").Get<string[]>() ?? [];
18-
var exposed = policySection.GetSection("ExposedHeaders").Get<string[]>() ?? [];
19-
var allowCredentials = policySection.GetValue<bool>("AllowCredentials");
20-
var preflightMaxAgeSeconds = policySection.GetValue<int?>("PreflightMaxAgeSeconds");
21-
22-
if (allowCredentials && origins.Contains("*"))
23-
{
24-
throw new InvalidOperationException(
25-
$"CORS policy '{name}' has AllowCredentials=true with AllowedOrigins '*'. " +
26-
"Specify explicit origins when credentials are allowed.");
27-
}
28-
29-
options.AddPolicy(name, policy =>
30-
{
31-
if (origins is ["*"]) policy.AllowAnyOrigin(); else if (origins.Length > 0) policy.WithOrigins(origins);
32-
if (methods is ["*"]) policy.AllowAnyMethod(); else if (methods.Length > 0) policy.WithMethods(methods);
33-
if (headers is ["*"]) policy.AllowAnyHeader(); else if (headers.Length > 0) policy.WithHeaders(headers);
34-
if (exposed.Length > 0) policy.WithExposedHeaders(exposed);
35-
if (allowCredentials) policy.AllowCredentials();
36-
if (preflightMaxAgeSeconds is int seconds) policy.SetPreflightMaxAge(TimeSpan.FromSeconds(seconds));
37-
});
38-
}
39-
});
11+
CorsPolicyConfigurator.Configure(options, builder.Configuration.GetSection("Cors:Policies")));
4012

4113
var app = builder.Build();
4214

4315
app.UseRouting();
4416
app.UseCors();
4517
app.MapReverseProxy();
4618
app.Run();
19+
20+
public partial class Program;

src/Proxy/Proxy.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,8 @@
1010
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
1111
</ItemGroup>
1212

13+
<ItemGroup>
14+
<InternalsVisibleTo Include="Proxy.Tests" />
15+
</ItemGroup>
16+
1317
</Project>

src/Proxy/Transforms/InjectJsonFieldsTransform.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,16 @@ public override async ValueTask ApplyAsync(RequestTransformContext context)
3535
obj[key] = TryParseJson(value);
3636
}
3737

38-
var merged = obj.ToJsonString();
39-
context.ProxyRequest.Content = new StringContent(merged, Encoding.UTF8, "application/json");
38+
var merged = Encoding.UTF8.GetBytes(obj.ToJsonString());
39+
request.Body = new MemoryStream(merged);
40+
request.ContentLength = merged.Length;
41+
42+
// YARP copies Content-Length onto ProxyRequest.Content before request transforms run,
43+
// so the previously-copied length must be updated to match the rewritten body.
44+
if (context.ProxyRequest.Content is not null)
45+
{
46+
context.ProxyRequest.Content.Headers.ContentLength = merged.Length;
47+
}
4048
}
4149

4250
private static JsonNode? TryParseJson(string value)
@@ -48,7 +56,7 @@ public override async ValueTask ApplyAsync(RequestTransformContext context)
4856

4957
internal sealed class InjectJsonFieldsTransformProvider : ITransformProvider
5058
{
51-
private const string Prefix = "InjectJsonField:";
59+
private const string Prefix = "InjectJsonField.";
5260

5361
public void ValidateRoute(TransformRouteValidationContext context) { }
5462
public void ValidateCluster(TransformClusterValidationContext context) { }
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using AspireTunnelProxy.Cors;
2+
using Microsoft.AspNetCore.Cors.Infrastructure;
3+
using Microsoft.Extensions.Configuration;
4+
5+
namespace Proxy.Tests.Cors;
6+
7+
public class CorsPolicyConfiguratorTests
8+
{
9+
[Fact]
10+
public void Wildcard_origins_methods_headers_map_to_allow_any()
11+
{
12+
var options = Configure(new Dictionary<string, string?>
13+
{
14+
["p:AllowedOrigins:0"] = "*",
15+
["p:AllowedMethods:0"] = "*",
16+
["p:AllowedHeaders:0"] = "*",
17+
});
18+
19+
var policy = options.GetPolicy("p")!;
20+
Assert.True(policy.AllowAnyOrigin);
21+
Assert.True(policy.AllowAnyMethod);
22+
Assert.True(policy.AllowAnyHeader);
23+
}
24+
25+
[Fact]
26+
public void Explicit_values_map_to_with_lists()
27+
{
28+
var options = Configure(new Dictionary<string, string?>
29+
{
30+
["p:AllowedOrigins:0"] = "https://a.example",
31+
["p:AllowedOrigins:1"] = "https://b.example",
32+
["p:AllowedMethods:0"] = "GET",
33+
["p:AllowedMethods:1"] = "POST",
34+
["p:AllowedHeaders:0"] = "X-Custom",
35+
["p:ExposedHeaders:0"] = "X-Trace-Id",
36+
});
37+
38+
var policy = options.GetPolicy("p")!;
39+
Assert.False(policy.AllowAnyOrigin);
40+
Assert.Contains("https://a.example", policy.Origins);
41+
Assert.Contains("https://b.example", policy.Origins);
42+
Assert.Contains("GET", policy.Methods);
43+
Assert.Contains("POST", policy.Methods);
44+
Assert.Contains("X-Custom", policy.Headers);
45+
Assert.Contains("X-Trace-Id", policy.ExposedHeaders);
46+
}
47+
48+
[Fact]
49+
public void AllowCredentials_true_enables_credentials()
50+
{
51+
var options = Configure(new Dictionary<string, string?>
52+
{
53+
["p:AllowedOrigins:0"] = "https://a.example",
54+
["p:AllowCredentials"] = "true",
55+
});
56+
57+
var policy = options.GetPolicy("p")!;
58+
Assert.True(policy.SupportsCredentials);
59+
}
60+
61+
[Fact]
62+
public void Preflight_max_age_seconds_populates_policy()
63+
{
64+
var options = Configure(new Dictionary<string, string?>
65+
{
66+
["p:AllowedOrigins:0"] = "*",
67+
["p:PreflightMaxAgeSeconds"] = "600",
68+
});
69+
70+
var policy = options.GetPolicy("p")!;
71+
Assert.Equal(TimeSpan.FromSeconds(600), policy.PreflightMaxAge);
72+
}
73+
74+
[Fact]
75+
public void Throws_when_credentials_with_wildcard_origin()
76+
{
77+
var ex = Assert.Throws<InvalidOperationException>(() => Configure(new Dictionary<string, string?>
78+
{
79+
["bad:AllowedOrigins:0"] = "*",
80+
["bad:AllowCredentials"] = "true",
81+
}));
82+
83+
Assert.Contains("bad", ex.Message);
84+
}
85+
86+
[Fact]
87+
public void Multiple_policies_all_get_registered()
88+
{
89+
var options = Configure(new Dictionary<string, string?>
90+
{
91+
["p1:AllowedOrigins:0"] = "*",
92+
["p2:AllowedOrigins:0"] = "https://a.example",
93+
});
94+
95+
Assert.NotNull(options.GetPolicy("p1"));
96+
Assert.NotNull(options.GetPolicy("p2"));
97+
}
98+
99+
private static CorsOptions Configure(IDictionary<string, string?> values)
100+
{
101+
var config = new ConfigurationBuilder()
102+
.AddInMemoryCollection(values)
103+
.Build();
104+
105+
var options = new CorsOptions();
106+
CorsPolicyConfigurator.Configure(options, config);
107+
return options;
108+
}
109+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System.Text.Json;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Hosting;
4+
using Microsoft.AspNetCore.Hosting.Server;
5+
using Microsoft.AspNetCore.Hosting.Server.Features;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Hosting;
9+
using Microsoft.Extensions.Logging;
10+
11+
namespace Proxy.Tests.Integration;
12+
13+
internal sealed class EchoUpstreamServer : IAsyncDisposable
14+
{
15+
private readonly WebApplication _app;
16+
17+
public string BaseUrl { get; }
18+
19+
private EchoUpstreamServer(WebApplication app, string baseUrl)
20+
{
21+
_app = app;
22+
BaseUrl = baseUrl;
23+
}
24+
25+
public static async Task<EchoUpstreamServer> StartAsync()
26+
{
27+
var builder = WebApplication.CreateSlimBuilder();
28+
builder.Logging.ClearProviders();
29+
builder.WebHost.UseUrls("http://127.0.0.1:0");
30+
31+
var app = builder.Build();
32+
app.Run(async ctx =>
33+
{
34+
using var reader = new StreamReader(ctx.Request.Body);
35+
var body = await reader.ReadToEndAsync();
36+
var headers = ctx.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
37+
38+
var payload = new EchoPayload
39+
{
40+
Method = ctx.Request.Method,
41+
Path = ctx.Request.Path.ToString(),
42+
ContentType = ctx.Request.ContentType,
43+
Headers = headers,
44+
Body = body,
45+
};
46+
47+
ctx.Response.ContentType = "application/json";
48+
await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload));
49+
});
50+
51+
await app.StartAsync();
52+
53+
var address = app.Services.GetRequiredService<IServer>()
54+
.Features.Get<IServerAddressesFeature>()!
55+
.Addresses.First();
56+
57+
return new EchoUpstreamServer(app, address);
58+
}
59+
60+
public async ValueTask DisposeAsync()
61+
{
62+
await _app.StopAsync();
63+
await _app.DisposeAsync();
64+
}
65+
}
66+
67+
internal sealed class EchoPayload
68+
{
69+
public string Method { get; set; } = "";
70+
public string Path { get; set; } = "";
71+
public string? ContentType { get; set; }
72+
public Dictionary<string, string> Headers { get; set; } = new();
73+
public string Body { get; set; } = "";
74+
}

0 commit comments

Comments
 (0)