feat(stability): unify readiness and recovery
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped

This commit is contained in:
AzuTear
2026-08-01 01:21:33 +02:00
parent 38282e4f7f
commit cd8c78d165
67 changed files with 2616 additions and 601 deletions
+10 -3
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Http;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
@@ -74,7 +75,9 @@ public class AdminController(
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
if (existing is not null)
return Results.Conflict(new { error = "A user with this email already exists." });
return NexusHttpResults.Problem(
StatusCodes.Status409Conflict,
"A user with this email already exists.");
var user = new NexusUser
{
@@ -108,7 +111,9 @@ public class AdminController(
{
var user = await userRepository.GetByIdAsync(id, ct);
if (user is null)
return Results.NotFound(new { error = "User not found." });
return NexusHttpResults.Problem(
StatusCodes.Status404NotFound,
"User not found.");
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
return Results.Problem("Owner accounts cannot be deleted via API.", statusCode: 403);
@@ -142,7 +147,9 @@ public class AdminController(
var user = await userRepository.GetByIdAsync(id, ct);
if (user is null)
return Results.NotFound(new { error = "User not found." });
return NexusHttpResults.Problem(
StatusCodes.Status404NotFound,
"User not found.");
// Niemals owner überschreiben
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
+21 -11
View File
@@ -5,6 +5,7 @@ using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Http;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
@@ -160,9 +161,15 @@ public class AgentsController(
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{
if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." });
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = ["Content is required."]
});
if (string.IsNullOrWhiteSpace(request.ExpectedHash))
return Results.BadRequest(new { error = "ExpectedHash is required." });
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["expectedHash"] = ["ExpectedHash is required."]
});
try
{
@@ -170,7 +177,10 @@ public class AgentsController(
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
return Results.BadRequest(new { error = "Idempotency-Key header is required." });
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Idempotency-Key"] = ["Idempotency-Key header is required."]
});
}
var invocation = OpenClawInvocationContext.Create(
@@ -204,15 +214,15 @@ public class AgentsController(
}
catch (OpenClawAgentConfigurationConflictException ex)
{
return Results.Json(
new
return NexusHttpResults.Problem(
StatusCodes.Status409Conflict,
ex.Message,
NexusProblemCodes.Conflict,
extensions: new Dictionary<string, object?>
{
code = ex.Code,
message = ex.Message,
ex.ExpectedHash,
ex.CurrentHash
},
statusCode: StatusCodes.Status409Conflict);
["expectedHash"] = ex.ExpectedHash,
["currentHash"] = ex.CurrentHash
});
}
catch (OpenClawAgentConfigurationValidationException ex)
{
+40 -20
View File
@@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Http;
using Nexus.Api.RateLimiting;
using Nexus.Api.Security;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -14,19 +15,10 @@ namespace Nexus.Api.Controllers;
[Route("api/v1/auth")]
public class AuthController(
IAuthService authService,
IAntiforgery antiforgery,
IConfiguration config,
IHostEnvironment env,
LoginAttemptTracker attemptTracker) : ControllerBase
{
[HttpGet("csrf")]
[AllowAnonymous]
public IActionResult GetCsrfToken()
{
var tokens = antiforgery.GetAndStoreTokens(HttpContext);
return Ok(new { token = tokens.RequestToken });
}
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
@@ -51,14 +43,16 @@ public class AuthController(
HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
// Return a structured body so the frontend can display remaining attempts
return Results.Json(new
{
error = "invalid_credentials",
message = "Invalid email or password.",
remaining,
retryAfterSeconds
}, statusCode: 401);
return Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "Authentication failed",
detail: "Invalid email or password.",
extensions: new Dictionary<string, object?>
{
["code"] = NexusProblemCodes.Unauthenticated,
["remaining"] = remaining,
["retryAfterSeconds"] = retryAfterSeconds
});
}
// Success — reset attempt counter
@@ -75,14 +69,17 @@ public class AuthController(
[EnableRateLimiting("auth")]
public async Task<IResult> Refresh(CancellationToken ct)
{
if (!BrowserRequestOriginGuard.IsAllowed(Request))
return CrossSiteRequestRejected();
if (!Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
return Results.Unauthorized();
return Unauthenticated("No active refresh session was provided.");
var session = await authService.RefreshAsync(refreshToken!, ct);
if (session is null)
{
ClearRefreshCookie(Response);
return Results.Unauthorized();
return Unauthenticated("The refresh session is invalid or expired.");
}
SetRefreshCookie(Response, session.RefreshToken);
@@ -96,6 +93,9 @@ public class AuthController(
[AllowAnonymous]
public async Task<IResult> Logout(CancellationToken ct)
{
if (!BrowserRequestOriginGuard.IsAllowed(Request))
return CrossSiteRequestRejected();
if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
await authService.RevokeAsync(refreshToken!, ct);
@@ -168,6 +168,26 @@ public class AuthController(
User = session.User
};
private static IResult Unauthenticated(string detail)
=> Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "Authentication required",
detail: detail,
extensions: new Dictionary<string, object?>
{
["code"] = NexusProblemCodes.Unauthenticated
});
private static IResult CrossSiteRequestRejected()
=> Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Cross-site request rejected",
detail: "Refresh and logout requests must originate from the Nexus origin.",
extensions: new Dictionary<string, object?>
{
["code"] = NexusProblemCodes.Forbidden
});
private void SetRefreshCookie(HttpResponse response, string token)
{
var days = config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;
+20
View File
@@ -18,6 +18,26 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
agentSource = "openclaw-rpc"
});
[AllowAnonymous]
[HttpGet("/health/ready")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> Ready(CancellationToken ct)
{
var report = await healthChecks.CheckHealthAsync(
registration => registration.Tags.Contains("ready"),
ct);
var payload = new
{
status = report.Status == HealthStatus.Healthy ? "Healthy" : "Unhealthy",
timestamp = DateTimeOffset.UtcNow
};
return report.Status == HealthStatus.Healthy
? Results.Ok(payload)
: Results.Json(payload, statusCode: StatusCodes.Status503ServiceUnavailable);
}
[AllowAnonymous]
[HttpGet("/health")]
public async Task<IResult> Get(CancellationToken ct)
@@ -1,4 +1,4 @@
using Nexus.Api.Models;
using Nexus.Api.Http;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -28,21 +28,32 @@ internal static class OpenClawContentReadEndpoint
"disconnected" => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status409Conflict
};
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
exception.State,
exception.Message,
exception.Method,
exception.RequiredScope),
statusCode: status);
return NexusHttpResults.Problem(
status,
exception.Message,
status switch
{
StatusCodes.Status403Forbidden => NexusProblemCodes.Forbidden,
StatusCodes.Status503ServiceUnavailable => NexusProblemCodes.DependencyUnavailable,
_ => NexusProblemCodes.Conflict
},
extensions: new Dictionary<string, object?>
{
["method"] = exception.Method,
["requiredScope"] = exception.RequiredScope,
["state"] = exception.State
});
}
catch (OpenClawAgentConfigurationVerificationException)
{
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
"verification_failed",
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."),
statusCode: StatusCodes.Status502BadGateway);
return NexusHttpResults.Problem(
StatusCodes.Status502BadGateway,
"OpenClaw-Antwort konnte nicht sicher verifiziert werden.",
NexusProblemCodes.DependencyUnavailable,
extensions: new Dictionary<string, object?>
{
["state"] = "verification_failed"
});
}
catch (OpenClawGatewayRpcException exception)
{
@@ -60,22 +71,33 @@ internal static class OpenClawContentReadEndpoint
StatusCodes.Status504GatewayTimeout,
_ => StatusCodes.Status502BadGateway
};
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
code.ToLowerInvariant(),
status switch
{
StatusCodes.Status403Forbidden =>
"OpenClaw hat Nexus nicht die erforderliche Leseberechtigung gewährt.",
StatusCodes.Status409Conflict =>
"Die verbundene OpenClaw-Version unterstützt diese Leseoperation nicht.",
StatusCodes.Status503ServiceUnavailable =>
"OpenClaw Gateway ist nicht verfügbar.",
StatusCodes.Status504GatewayTimeout =>
"OpenClaw hat nicht rechtzeitig geantwortet.",
_ => "OpenClaw-Leseoperation ist fehlgeschlagen."
}),
statusCode: status);
var problemCode = status switch
{
StatusCodes.Status403Forbidden => NexusProblemCodes.Forbidden,
StatusCodes.Status409Conflict => NexusProblemCodes.UnsupportedCapability,
StatusCodes.Status503ServiceUnavailable => NexusProblemCodes.DependencyUnavailable,
StatusCodes.Status504GatewayTimeout => NexusProblemCodes.Timeout,
_ => NexusProblemCodes.DependencyUnavailable
};
return NexusHttpResults.Problem(
status,
status switch
{
StatusCodes.Status403Forbidden =>
"OpenClaw hat Nexus nicht die erforderliche Leseberechtigung gewährt.",
StatusCodes.Status409Conflict =>
"Die verbundene OpenClaw-Version unterstützt diese Leseoperation nicht.",
StatusCodes.Status503ServiceUnavailable =>
"OpenClaw Gateway ist nicht verfügbar.",
StatusCodes.Status504GatewayTimeout =>
"OpenClaw hat nicht rechtzeitig geantwortet.",
_ => "OpenClaw-Leseoperation ist fehlgeschlagen."
},
problemCode,
extensions: new Dictionary<string, object?>
{
["state"] = code.ToLowerInvariant()
});
}
}
}
+5 -1
View File
@@ -1,9 +1,13 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG NEXUS_VERSION=dev
ARG NEXUS_GIT_SHA=unknown
WORKDIR /src
COPY Nexus.Api.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
RUN dotnet publish -c Release -o /app/publish \
/p:Version="${NEXUS_VERSION}" \
/p:InformationalVersion="${NEXUS_VERSION}+${NEXUS_GIT_SHA}"
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
ARG NEXUS_VERSION=dev
@@ -1,5 +1,6 @@
using System.Diagnostics;
using Nexus.Api.Observability;
using Nexus.Api.Http;
using Npgsql;
using OpenTelemetry;
using OpenTelemetry.Exporter;
@@ -20,14 +21,14 @@ public static class PlatformServiceCollectionExtensions
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOpenApi("v1");
services.AddOpenApi("v1", options =>
options.AddSchemaTransformer<NexusProblemDetailsSchemaTransformer>());
services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
};
NexusProblemDetailsDefaults.Apply(
context.ProblemDetails,
context.HttpContext);
});
var telemetry = services.AddOpenTelemetry()
@@ -10,6 +10,7 @@ using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Http;
using Nexus.Api.RateLimiting;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
@@ -28,7 +29,7 @@ namespace Nexus.Api.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configures JWT authentication, authorization, and antiforgery.
/// Configures JWT authentication and authorization.
/// </summary>
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
{
@@ -63,14 +64,6 @@ public static class ServiceCollectionExtensions
.RequireAuthenticatedUser()
.Build();
});
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
return services;
}
@@ -109,7 +102,7 @@ public static class ServiceCollectionExtensions
Status = StatusCodes.Status429TooManyRequests,
Detail = $"Too many attempts. Try again in {retryAfterSeconds} second(s)."
};
body.Extensions["code"] = "rate_limit_exceeded";
body.Extensions["code"] = NexusProblemCodes.RateLimited;
body.Extensions["remaining"] = 0;
body.Extensions["retryAfterSeconds"] = retryAfterSeconds;
body.Extensions["traceId"] =
@@ -272,11 +265,15 @@ public static class ServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddNexusApplicationServices(
this IServiceCollection services,
bool includeHostedServices = true)
bool includeHostedServices = true,
bool includeMcp = true)
{
services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
.WithTools<NexusMcpTools>();
if (includeMcp)
{
services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
.WithTools<NexusMcpTools>();
}
services.AddOptions<StaleTaskRecoveryOptions>()
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
@@ -364,7 +361,10 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
{
services.AddHealthChecks()
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddNpgSql(
configuration.GetConnectionString("Nexus")!,
name: "postgresql",
tags: ["database", "ready"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
return services;
+137
View File
@@ -0,0 +1,137 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.WebUtilities;
namespace Nexus.Api.Http;
public static class NexusProblemCodes
{
public const string ValidationFailed = "validation_failed";
public const string Unauthenticated = "unauthenticated";
public const string Forbidden = "forbidden";
public const string NotFound = "not_found";
public const string Conflict = "conflict";
public const string UnsupportedCapability = "unsupported_capability";
public const string DependencyUnavailable = "dependency_unavailable";
public const string Timeout = "timeout";
public const string RateLimited = "rate_limited";
public const string InternalError = "internal_error";
public static string ForStatus(int statusCode) => statusCode switch
{
StatusCodes.Status400BadRequest or StatusCodes.Status422UnprocessableEntity => ValidationFailed,
StatusCodes.Status401Unauthorized => Unauthenticated,
StatusCodes.Status403Forbidden => Forbidden,
StatusCodes.Status404NotFound => NotFound,
StatusCodes.Status409Conflict => Conflict,
StatusCodes.Status429TooManyRequests => RateLimited,
StatusCodes.Status501NotImplemented => UnsupportedCapability,
StatusCodes.Status502BadGateway or StatusCodes.Status503ServiceUnavailable => DependencyUnavailable,
StatusCodes.Status504GatewayTimeout => Timeout,
_ => InternalError
};
}
public static class NexusProblemDetailsDefaults
{
public static void Apply(ProblemDetails problem, HttpContext httpContext)
{
var statusCode = problem.Status
?? (httpContext.Response.StatusCode >= 400
? httpContext.Response.StatusCode
: StatusCodes.Status500InternalServerError);
problem.Status = statusCode;
problem.Type ??= $"https://httpstatuses.com/{statusCode}";
problem.Title ??= ReasonPhrases.GetReasonPhrase(statusCode);
problem.Extensions.TryAdd("code", NexusProblemCodes.ForStatus(statusCode));
problem.Extensions.TryAdd(
"traceId",
Activity.Current?.Id ?? httpContext.TraceIdentifier);
}
public static ProblemDetails? FromLegacyError(
object? value,
int? statusCode,
HttpContext httpContext)
{
if (value is null || statusCode is null || statusCode < 400)
return null;
var errorProperty = value.GetType().GetProperty(
"error",
System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.IgnoreCase);
if (errorProperty?.GetValue(value) is not string detail
|| string.IsNullOrWhiteSpace(detail))
{
return null;
}
var problem = new ProblemDetails
{
Status = statusCode,
Detail = detail
};
Apply(problem, httpContext);
return problem;
}
}
public static class NexusHttpResults
{
public static IResult Problem(
int statusCode,
string detail,
string? code = null,
string? title = null,
IDictionary<string, object?>? extensions = null)
{
var values = extensions is null
? new Dictionary<string, object?>()
: new Dictionary<string, object?>(extensions);
values.TryAdd("code", code ?? NexusProblemCodes.ForStatus(statusCode));
return Results.Problem(
statusCode: statusCode,
title: title,
detail: detail,
extensions: values);
}
}
/// <summary>
/// Ensures MVC-produced ProblemDetails values use the same extensions as the
/// global exception and status-code writers.
/// </summary>
public sealed class NexusProblemDetailsFilter : IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context)
{
if (context.Result is not ObjectResult objectResult)
return;
if (objectResult.Value is ProblemDetails problem)
{
NexusProblemDetailsDefaults.Apply(problem, context.HttpContext);
return;
}
var legacyProblem = NexusProblemDetailsDefaults.FromLegacyError(
objectResult.Value,
objectResult.StatusCode,
context.HttpContext);
if (legacyProblem is null)
return;
objectResult.Value = legacyProblem;
objectResult.ContentTypes.Clear();
objectResult.ContentTypes.Add("application/problem+json");
}
public void OnResultExecuted(ResultExecutedContext context)
{
}
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Nexus.Api.Http;
/// <summary>
/// Publishes the stable Nexus ProblemDetails extensions in the generated
/// OpenAPI contract so the frontend does not have to guess error metadata.
/// </summary>
public sealed class NexusProblemDetailsSchemaTransformer : IOpenApiSchemaTransformer
{
public Task TransformAsync(
OpenApiSchema schema,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
if (!typeof(ProblemDetails).IsAssignableFrom(context.JsonTypeInfo.Type))
return Task.CompletedTask;
schema.Properties ??= new Dictionary<string, IOpenApiSchema>();
schema.Properties["code"] = StringSchema(
"Stable machine-readable Nexus error code.");
schema.Properties["traceId"] = StringSchema(
"Privacy-safe server trace identifier.");
schema.Properties["operationId"] = StringSchema(
"Durable operation identifier when the request started an operation.");
schema.Properties["currentRevision"] = IntegerSchema(
"Current server revision for a stale-write conflict.");
schema.Properties["retryAfterSeconds"] = IntegerSchema(
"Minimum retry delay advertised by Nexus.");
schema.Properties["remaining"] = IntegerSchema(
"Remaining attempts when a bounded policy exposes that value.");
schema.Properties["expectedHash"] = StringSchema(
"Client-supplied content hash for a stale-write conflict.");
schema.Properties["currentHash"] = StringSchema(
"Current server content hash for a stale-write conflict.");
schema.Required ??= new HashSet<string>();
schema.Required.Add("code");
schema.Required.Add("traceId");
return Task.CompletedTask;
}
private static OpenApiSchema StringSchema(string description)
=> new()
{
Type = JsonSchemaType.String | JsonSchemaType.Null,
Description = description
};
private static OpenApiSchema IntegerSchema(string description)
=> new()
{
Type = JsonSchemaType.Integer | JsonSchemaType.Null,
Format = "int32",
Description = description
};
}
+7 -3
View File
@@ -1,4 +1,5 @@
using Nexus.Api.Extensions;
using Nexus.Api.Http;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
@@ -20,11 +21,13 @@ builder.Services.AddNexusSwagger();
builder.Services.AddNexusDatabase(builder.Configuration);
builder.Services.AddNexusHttpClients(builder.Configuration);
builder.Services.AddNexusApplicationServices(
includeHostedServices: !isOpenApiGeneration);
includeHostedServices: !isOpenApiGeneration,
includeMcp: !isOpenApiGeneration);
builder.Services.AddNexusRepositories();
builder.Services.AddNexusHealthChecks(builder.Configuration);
builder.Services.AddNexusPlatform(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddControllers(options =>
options.Filters.Add<NexusProblemDetailsFilter>());
var app = builder.Build();
@@ -40,7 +43,8 @@ if (!isOpenApiGeneration)
// --- Middleware Pipeline ---
app.UseNexusPipeline(app.Environment);
app.MapMcp("/mcp");
if (!isOpenApiGeneration)
app.MapMcp("/mcp");
app.MapOpenApi("/openapi/{documentName}.json");
app.MapControllers();
app.Run();
@@ -0,0 +1,37 @@
namespace Nexus.Api.Security;
/// <summary>
/// Rejects browser requests that explicitly identify themselves as cross-site.
/// Requests without browser provenance headers remain valid for trusted API
/// clients; they still need the normal cookie, authentication and rate limits.
/// </summary>
public static class BrowserRequestOriginGuard
{
public static bool IsAllowed(HttpRequest request)
{
var fetchSite = request.Headers["Sec-Fetch-Site"].ToString().Trim();
if (string.Equals(fetchSite, "cross-site", StringComparison.OrdinalIgnoreCase))
return false;
var originValue = request.Headers.Origin.ToString().Trim();
if (originValue.Length == 0)
return true;
if (!Uri.TryCreate(originValue, UriKind.Absolute, out var origin))
return false;
var requestHost = request.Host.Host;
if (requestHost.Length == 0)
return false;
var originPort = origin.IsDefaultPort ? DefaultPort(origin.Scheme) : origin.Port;
var requestPort = request.Host.Port ?? DefaultPort(request.Scheme);
return string.Equals(origin.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(origin.Host, requestHost, StringComparison.OrdinalIgnoreCase)
&& originPort == requestPort;
}
private static int DefaultPort(string scheme)
=> string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80;
}
+141 -12
View File
@@ -585,18 +585,6 @@
}
}
},
"/api/v1/auth/csrf": {
"get": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v1/auth/login": {
"post": {
"tags": [
@@ -3023,6 +3011,21 @@
}
}
},
"/health/ready": {
"get": {
"tags": [
"Health"
],
"responses": {
"200": {
"description": "OK"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/health": {
"get": {
"tags": [
@@ -15008,6 +15011,10 @@
}
},
"ProblemDetails": {
"required": [
"code",
"traceId"
],
"type": "object",
"properties": {
"type": {
@@ -15042,6 +15049,65 @@
"null",
"string"
]
},
"code": {
"type": [
"null",
"string"
],
"description": "Stable machine-readable Nexus error code."
},
"traceId": {
"type": [
"null",
"string"
],
"description": "Privacy-safe server trace identifier."
},
"operationId": {
"type": [
"null",
"string"
],
"description": "Durable operation identifier when the request started an operation."
},
"currentRevision": {
"type": [
"null",
"integer"
],
"description": "Current server revision for a stale-write conflict.",
"format": "int32"
},
"retryAfterSeconds": {
"type": [
"null",
"integer"
],
"description": "Minimum retry delay advertised by Nexus.",
"format": "int32"
},
"remaining": {
"type": [
"null",
"integer"
],
"description": "Remaining attempts when a bounded policy exposes that value.",
"format": "int32"
},
"expectedHash": {
"type": [
"null",
"string"
],
"description": "Client-supplied content hash for a stale-write conflict."
},
"currentHash": {
"type": [
"null",
"string"
],
"description": "Current server content hash for a stale-write conflict."
}
}
},
@@ -15899,6 +15965,10 @@
}
},
"ValidationProblemDetails": {
"required": [
"code",
"traceId"
],
"type": "object",
"properties": {
"type": {
@@ -15942,6 +16012,65 @@
"type": "string"
}
}
},
"code": {
"type": [
"null",
"string"
],
"description": "Stable machine-readable Nexus error code."
},
"traceId": {
"type": [
"null",
"string"
],
"description": "Privacy-safe server trace identifier."
},
"operationId": {
"type": [
"null",
"string"
],
"description": "Durable operation identifier when the request started an operation."
},
"currentRevision": {
"type": [
"null",
"integer"
],
"description": "Current server revision for a stale-write conflict.",
"format": "int32"
},
"retryAfterSeconds": {
"type": [
"null",
"integer"
],
"description": "Minimum retry delay advertised by Nexus.",
"format": "int32"
},
"remaining": {
"type": [
"null",
"integer"
],
"description": "Remaining attempts when a bounded policy exposes that value.",
"format": "int32"
},
"expectedHash": {
"type": [
"null",
"string"
],
"description": "Client-supplied content hash for a stale-write conflict."
},
"currentHash": {
"type": [
"null",
"string"
],
"description": "Current server content hash for a stale-write conflict."
}
}
},