Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions backend/src/CodeSpace.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ public static void Main(string[] args)
environment,
string.IsNullOrWhiteSpace(seqDestination) ? "none — Seq is off, set " + SerilogServerUrlSetting.ConfigurationKey + " to enable it" : seqDestination);

// Where the links this server MINTS will point. It is the SPA's origin, not this API's,
// and the two are usually different hosts -- an invite pointed at the API opens a path
// only the SPA has, which is a 404 from a server the invitee has no account on. Printing
// it costs a line and turns that into something an operator sees before sending one.
//
// The RAW key, deliberately, not PublicBaseUrlSetting: that type throws outside
// Development when the key is unset, and it is resolved lazily today -- only by the two
// request-scoped services that mint links. Constructing it here would move that failure
// to boot, for both roles, and Main catches it into a Fatal log and returns 0, so the
// container would exit CLEANLY and every restart would look like a healthy stop. A line
// that reports configuration must not be able to change what configuration does.
Log.Information("Invite and password-reset links will point at {PublicBaseUrl} (this must be the web app, not this API)",
configuration[PublicBaseUrlSetting.ConfigurationKey] is { Length: > 0 } configured
? configured
: $"(unset — set {PublicBaseUrlSetting.ConfigurationKey}; outside Development, minting a link will fail)");

Log.Information("Configuring {Application} host...", application);

new DbUpRunner(new CodeSpaceConnectionString(configuration).Value).Run();
Expand Down
26 changes: 26 additions & 0 deletions backend/src/CodeSpace.Api/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using CodeSpace.Api.Extensions;
using CodeSpace.Messages.Failures;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Services.Workflows.Llm;
using CodeSpace.Api.Filters;
Expand Down Expand Up @@ -128,6 +129,31 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)

endpoints.MapControllers();
if (env.IsDevelopment()) endpoints.MapOpenApi();

// A path this server does not serve answers 404, not 401. The global FallbackPolicy
// requires an authenticated user for any endpoint without an explicit [Authorize] --
// deliberately -- and it applied to requests that matched NOTHING too, where the answer
// is a lie: the request was refused for want of a route, not a session, and adding one
// would not have helped. That cost a real diagnosis, an invite link built from a
// misconfigured App:PublicBaseUrl pointed at this API instead of the SPA, so opening it
// asked here for /invite/{token} -- a path only the SPA has -- and the 401 sent everyone
// hunting a broken authorization rule on an endpoint that was already anonymous.
//
// A fallback ENDPOINT rather than middleware before UseAuthorization, because only the
// endpoint form composes with branch middleware. Hangfire's dashboard is mounted by
// app.Map (above, line ~124), which never registers an endpoint, so a middleware keyed
// on "GetEndpoint() == null" would have swallowed /hangfire whole. Routing reaches this
// only after every real endpoint AND every earlier branch has declined.
endpoints.MapFallback(async context =>
{
context.Response.StatusCode = StatusCodes.Status404NotFound;

await context.Response.WriteAsJsonAsync(new Dictionary<string, object?>
{
["code"] = FailureCodes.NotFound,
["message"] = $"This server has no {context.Request.Path.Value}. Check the host — the API and the web app are different origins."
});
}).AllowAnonymous();
});
}
}
131 changes: 131 additions & 0 deletions backend/tests/CodeSpace.E2ETests/Auth/UnroutedRequestE2ETests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.Net;
using System.Net.Http.Json;
using CodeSpace.E2ETests.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Shouldly;

namespace CodeSpace.E2ETests.Auth;

/// <summary>
/// A path this server does not serve says so, and every path it DOES serve still requires a session.
///
/// <para>The second half is the one that must not regress. The global <c>FallbackPolicy</c> exists so
/// that an endpoint someone forgot to mark <c>[Authorize]</c> is refused rather than silently
/// anonymous; answering 404 for unmatched routes must not become a way to reach a matched one.</para>
/// </summary>
[Trait("Category", "E2E")]
[Trait("Surface", "Http")]
public sealed class UnroutedRequestE2ETests : IClassFixture<TaskLaunchApiFactory>
{
private readonly TaskLaunchApiFactory _factory;

public UnroutedRequestE2ETests(TaskLaunchApiFactory factory) { _factory = factory; }

/// <summary>
/// The exact shape that cost a production diagnosis: an invite link built from a misconfigured
/// public base URL opens the API host, and <c>/invite/{token}</c> is a route only the SPA has.
/// </summary>
[Fact]
public async Task A_web_app_route_requested_from_the_api_says_there_is_no_such_page()
{
var response = await _factory.CreateClient().GetAsync("/invite/some-token");

response.StatusCode.ShouldBe(HttpStatusCode.NotFound,
customMessage: "An unrouted path answered 401, which reads as 'you need to sign in' when the truth is " +
"'this server has never had this page'. That sent a real investigation after an " +
"authorization rule on an endpoint that was already anonymous and working.");

var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();

body!["message"].ShouldContain("no /invite/some-token");
body["message"].ShouldContain("different origins",
customMessage: "The message has to name the likely cause. A bare 404 is honest but still leaves the reader guessing.");
}

[Fact]
public async Task An_unknown_path_is_not_found_rather_than_unauthorized()
{
var response = await _factory.CreateClient().GetAsync("/nothing-here");

response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}

/// <summary>
/// The guard. A real endpoint with no explicit attribute still meets the fallback policy — this
/// is what stops the change above from turning into anonymous access.
/// </summary>
[Fact]
public async Task A_real_endpoint_still_refuses_an_unauthenticated_caller()
{
var response = await _factory.CreateClient().GetAsync("/api/repositories");

response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized,
customMessage: "A matched endpoint must still be challenged. If this is 404, the middleware is running " +
"for endpoints that DID match and the FallbackPolicy has been bypassed.");
}

/// <summary>An anonymous endpoint that exists must still run, not be swallowed as unrouted.</summary>
[Fact]
public async Task An_anonymous_endpoint_still_answers()
{
var response = await _factory.CreateClient().GetAsync("/api/invitations/not-a-real-token");

response.StatusCode.ShouldBe(HttpStatusCode.NotFound);

var body = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();

body!["code"].ShouldBe("invitation_not_usable",
customMessage: "This is the invitation endpoint's OWN 404 about a token, not the middleware's about a route. " +
"If the code is 'not_found', the middleware swallowed a matched endpoint.");
}

/// <summary>
/// The regression an adversarial review caught before this shipped. The first attempt answered
/// 404 from a middleware placed before <c>UseAuthorization</c>, keyed on
/// <c>GetEndpoint() == null</c> — and Hangfire's dashboard is mounted by <c>app.Map</c>, classic
/// branch middleware that never registers an endpoint. The middleware therefore swallowed
/// <c>/hangfire</c> whole and <c>UseCodeSpaceHangfire</c> became dead code on the API role.
///
/// <para>A fallback ENDPOINT cannot do that: routing reaches it only after every earlier branch
/// has declined. This asserts the property that makes it safe, so a future move back to
/// middleware fails here.</para>
/// </summary>
[Fact]
public async Task The_not_found_answer_does_not_swallow_branch_mounted_paths()
{
await using var apiRole = new HangfireApiRoleFactory(_factory);

var response = await apiRole.CreateClient().GetAsync("/hangfire");

var body = response.Content.Headers.ContentType?.MediaType == "application/json"
? await response.Content.ReadFromJsonAsync<Dictionary<string, string>>()
: null;

body?.GetValueOrDefault("code").ShouldNotBe("not_found",
customMessage: "/hangfire was answered by the catch-all rather than by Hangfire's own branch. The " +
"dashboard is mounted with app.Map and has no endpoint, so anything keyed on " +
"\"no endpoint matched\" unmounts it.");
}

/// <summary>Runs the same app with the Hangfire role that actually mounts the dashboard; the shared factory runs Worker, which mounts none.</summary>
private sealed class HangfireApiRoleFactory : WebApplicationFactory<CodeSpace.Api.Program>
{
private readonly TaskLaunchApiFactory _inner;

public HangfireApiRoleFactory(TaskLaunchApiFactory inner) { _inner = inner; }

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, cfg) => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["CodeSpaceStore:ConnectionString"] = _inner.ConnectionString,
["Authentication:Jwt:SymmetricKey"] = TaskLaunchApiFactory.JwtKey,
["OAuth:CallbackUrl"] = "http://localhost/api/credentials/oauth/callback",
["HangfireHosting"] = "Api",
}));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ async Task IAsyncLifetime.DisposeAsync()
await drop.ExecuteNonQueryAsync();
}

/// <summary>The per-run test database, so a sibling factory can host the same app in a different role against the same data.</summary>
public string ConnectionString => _testConnectionString;

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
Expand Down
Loading