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
21 changes: 21 additions & 0 deletions infra/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions infra/bff-forwarding.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { test } from "node:test"

const root = dirname(fileURLToPath(import.meta.url))
const configuration = readFileSync(join(root, "configuration.tf"), "utf8")
const variables = readFileSync(join(root, "variables.tf"), "utf8")

function block(header) {
const start = configuration.indexOf(header)
assert.notEqual(start, -1, `Missing ${header}`)
const opening = configuration.indexOf("{", start + header.length)
let depth = 0
for (let index = opening; index < configuration.length; index++) {
if (configuration[index] === "{") depth++
if (configuration[index] === "}" && --depth === 0) return configuration.slice(opening + 1, index)
}
assert.fail(`Unclosed ${header}`)
}

test("BFF forwarding secrets are generated per environment without operator inputs", () => {
assert.doesNotMatch(variables, /variable\s+"(?:production|staging)_bff_forwarding_secret"/)
const password = block('resource "random_password" "bff_forwarding_secret"')
assert.match(password, /for_each\s*=\s*toset\(\["production", "staging"\]\)/)
assert.match(password, /length\s*=\s*64\b/)
assert.match(password, /special\s*=\s*false\b/)
assert.doesNotMatch(password, /keepers|timestamp\(|plantimestamp\(/)
})

test("generated BFF forwarding secrets are persisted as environment SecureStrings", () => {
const parameter = block('resource "aws_ssm_parameter" "bff_forwarding_secret"')
assert.match(parameter, /for_each\s*=\s*random_password\.bff_forwarding_secret/)
assert.match(parameter, /name\s*=\s*"\/orbit\/\$\{each\.key\}\/api\/Auth__BffForwardingSecret"/)
assert.match(parameter, /type\s*=\s*"SecureString"/)
assert.match(parameter, /value\s*=\s*each\.value\.result/)
})

for (const environment of ["production", "staging"]) {
test(`${environment} API and web share their persisted forwarding secret`, () => {
for (const [service, key] of [["api", "Auth__BffForwardingSecret"], ["web", "BFF_FORWARDING_SECRET"]]) {
const group = block(`resource "render_env_group" "${environment}_${service}"`)
assert.match(group, new RegExp(`${key}\\s*=\\s*\\{\\s*value\\s*=\\s*aws_ssm_parameter\\.bff_forwarding_secret\\["${environment}"\\]\\.value\\s*\\}`))
assert.doesNotMatch(group, /var\.(?:production|staging)_bff_forwarding_secret/)
}
})
}
19 changes: 19 additions & 0 deletions infra/configuration.tf
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ data "aws_ssm_parameter" "staging_web_sentry" {
name = "/orbit/staging/web/SENTRY_DSN"
}

resource "random_password" "bff_forwarding_secret" {
for_each = toset(["production", "staging"])

length = 64
special = false
}

resource "aws_ssm_parameter" "bff_forwarding_secret" {
for_each = random_password.bff_forwarding_secret

name = "/orbit/${each.key}/api/Auth__BffForwardingSecret"
type = "SecureString"
value = each.value.result
}

resource "render_env_group" "production_api" {
name = "orbit-production-api"
env_vars = merge(
Expand All @@ -169,6 +184,7 @@ resource "render_env_group" "production_api" {
Storage__S3__SecretAccessKey = { value = aws_ssm_parameter.uploads_secret_access_key["production"].value }
},
{ BotProtection__SecretKey = { value = aws_ssm_parameter.turnstile_secret["production"].value } },
{ Auth__BffForwardingSecret = { value = aws_ssm_parameter.bff_forwarding_secret["production"].value } },
{ Ses__AccessKeyId = { value = aws_ssm_parameter.api_ses_access_key_id["production"].value } },
{ Ses__SecretAccessKey = { value = aws_ssm_parameter.api_ses_secret_access_key["production"].value } },
)
Expand All @@ -185,6 +201,7 @@ resource "render_env_group" "staging_api" {
Storage__S3__SecretAccessKey = { value = aws_ssm_parameter.uploads_secret_access_key["staging"].value }
},
{ BotProtection__SecretKey = { value = aws_ssm_parameter.turnstile_secret["staging"].value } },
{ Auth__BffForwardingSecret = { value = aws_ssm_parameter.bff_forwarding_secret["staging"].value } },
{ Ses__AccessKeyId = { value = aws_ssm_parameter.api_ses_access_key_id["staging"].value } },
{ Ses__SecretAccessKey = { value = aws_ssm_parameter.api_ses_secret_access_key["staging"].value } },
)
Expand All @@ -195,6 +212,7 @@ resource "render_env_group" "production_web" {
env_vars = merge(
{ for key, value in local.production_web_values : key => { value = value } },
{ SENTRY_DSN = { value = data.aws_ssm_parameter.production_web_sentry.value } },
{ BFF_FORWARDING_SECRET = { value = aws_ssm_parameter.bff_forwarding_secret["production"].value } },
)
}

Expand All @@ -203,5 +221,6 @@ resource "render_env_group" "staging_web" {
env_vars = merge(
{ for key, value in local.staging_web_values : key => { value = value } },
{ SENTRY_DSN = { value = data.aws_ssm_parameter.staging_web_sentry.value } },
{ BFF_FORWARDING_SECRET = { value = aws_ssm_parameter.bff_forwarding_secret["staging"].value } },
)
}
4 changes: 4 additions & 0 deletions infra/versions.tf
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ terraform {
source = "hashicorp/aws"
version = "= 6.66.0"
}
random = {
source = "hashicorp/random"
version = "= 3.9.1"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "= 5.26.0"
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,8 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu

public static WebApplicationBuilder AddOrbitRateLimiting(this WebApplicationBuilder builder)
{
builder.Services.Configure<BffForwardingSettings>(
builder.Configuration.GetSection(BffForwardingSettings.SectionName));
builder.Services.AddScoped<IDistributedRateLimitService, DistributedRateLimitService>();
builder.Services.AddRateLimiter(options =>
{
Expand Down
1 change: 1 addition & 0 deletions src/Orbit.Api/Extensions/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();

Check warning on line 60 in src/Orbit.Api/Extensions/WebApplicationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build

Call MapOpenApi().WithDocumentPerVersion() so that a document is generated for each API version (https://dotnet.github.io/aspnet-api-versioning/diagnostic/av0030.html)

Check warning on line 60 in src/Orbit.Api/Extensions/WebApplicationExtensions.cs

View workflow job for this annotation

GitHub Actions / Unit Tests

Call MapOpenApi().WithDocumentPerVersion() so that a document is generated for each API version (https://dotnet.github.io/aspnet-api-versioning/diagnostic/av0030.html)

Check warning on line 60 in src/Orbit.Api/Extensions/WebApplicationExtensions.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Call MapOpenApi().WithDocumentPerVersion() so that a document is generated for each API version (https://dotnet.github.io/aspnet-api-versioning/diagnostic/av0030.html)
app.MapScalarApiReference(options => options
.AddDocument("v1", "Orbit API v1", isDefault: true)
.AddDocument("v2", "Orbit API v2"));
Expand Down Expand Up @@ -449,6 +449,7 @@
options.KnownProxies.Add(parsedProxy);
}

// https://github.com/thomasluizon/orbit-tickets/issues/114: Render staging logged peer 10.24.103.103 as the refresh IP bucket, so trust the configured private proxy network for one hop.
var knownNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? [];
foreach (var network in knownNetworks)
{
Expand Down
8 changes: 8 additions & 0 deletions src/Orbit.Api/RateLimiting/BffForwardingSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Orbit.Api.RateLimiting;

public sealed class BffForwardingSettings
{
public const string SectionName = "Auth";

public string BffForwardingSecret { get; set; } = "";
}
105 changes: 74 additions & 31 deletions src/Orbit.Api/RateLimiting/DistributedRateLimitAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System.Reflection;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using Orbit.Api.Extensions;
using Orbit.Application.Auth.Validators;
using Orbit.Application.Common;
Expand All @@ -21,55 +23,65 @@ public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
policyName,
serviceProvider.GetRequiredService<IDistributedRateLimitService>(),
serviceProvider.GetRequiredService<IAuthSessionService>(),
serviceProvider.GetRequiredService<IOptions<BffForwardingSettings>>(),
serviceProvider.GetRequiredService<ILogger<DistributedRateLimitFilter>>());
}

public sealed partial class DistributedRateLimitFilter(
string policyName,
IDistributedRateLimitService distributedRateLimitService,
IAuthSessionService authSessionService,
IOptions<BffForwardingSettings> bffForwardingOptions,
ILogger<DistributedRateLimitFilter> logger) : IAsyncActionFilter
{
private const string FailOpenPolicy = "support";

public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var partitionKey = await ResolvePartitionKeyAsync(
var partitions = await ResolvePartitionsAsync(
policyName,
context.HttpContext,
context.ActionArguments.Values,
context.HttpContext.RequestAborted);
DistributedRateLimitDecision decision;
(string PolicyName, string PartitionKey, DistributedRateLimitDecision Decision)? rejection = null;

try
{
decision = await distributedRateLimitService.TryAcquireAsync(
policyName,
partitionKey,
context.HttpContext.RequestAborted);
}
catch (Exception exception)
foreach (var partition in partitions)
{
LogRateLimitEvaluationFailed(
logger,
policyName,
partitionKey,
context.HttpContext.Request.Method,
context.HttpContext.Request.Path,
context.HttpContext.GetRequestId(),
exception);

if (string.Equals(policyName, FailOpenPolicy, StringComparison.OrdinalIgnoreCase))
DistributedRateLimitDecision decision;
try
{
await next();
return;
decision = await distributedRateLimitService.TryAcquireAsync(
partition.PolicyName,
partition.PartitionKey,
context.HttpContext.RequestAborted);
}
catch (Exception exception)
{
LogRateLimitEvaluationFailed(
logger,
partition.PolicyName,
partition.PartitionKey,
context.HttpContext.Request.Method,
context.HttpContext.Request.Path,
context.HttpContext.GetRequestId(),
exception);

if (string.Equals(policyName, FailOpenPolicy, StringComparison.OrdinalIgnoreCase))
{
await next();
return;
}

throw;
}

throw;
if (!decision.Allowed)
rejection ??= (partition.PolicyName, partition.PartitionKey, decision);
}

if (!decision.Allowed)
if (rejection is { } rejected)
{
var decision = rejected.Decision;
var retryAfterSeconds = Math.Max(
1,
(int)Math.Ceiling((decision.WindowEndsAtUtc - DateTime.UtcNow).TotalSeconds));
Expand All @@ -79,8 +91,8 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE
context.HttpContext.Response.Headers[Orbit.Api.Extensions.HttpContextExtensions.RequestIdHeaderName] = context.HttpContext.GetRequestId();
LogRateLimitRejected(
logger,
policyName,
partitionKey,
rejected.PolicyName,
rejected.PartitionKey,
decision.CurrentCount,
decision.PermitLimit,
retryAfterSeconds,
Expand All @@ -97,30 +109,61 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE
await next();
}

private async Task<string> ResolvePartitionKeyAsync(
private async Task<(string PolicyName, string PartitionKey)[]> ResolvePartitionsAsync(
string policyName,
HttpContext context,
IEnumerable<object?> actionArguments,
CancellationToken cancellationToken)
{
if (context.User.Identity?.IsAuthenticated == true)
return $"user:{context.GetUserId()}";
return [(policyName, $"user:{context.GetUserId()}")];

if (TryResolveEmailPartitionKey(policyName, actionArguments, out var emailPartitionKey))
return emailPartitionKey;
return [(policyName, emailPartitionKey), ($"{policyName.ToLowerInvariant()}-ip", $"ip:{GetEmailRequestSourceIp(context) ?? "unknown"}")];

if (TryExtractRefreshToken(policyName, actionArguments, out var refreshToken)
&& await authSessionService.HasSessionForTokenAsync(refreshToken, cancellationToken))
{
return BuildRefreshTokenPartitionKey(policyName, refreshToken);
return [(policyName, BuildRefreshTokenPartitionKey(policyName, refreshToken))];
}

return [(policyName, $"ip:{context.GetClientIpAddress() ?? "unknown"}")];
}

/// <summary>
/// Only a BFF authenticated by its separate forwarding secret may supply an end-client IP.
/// Direct requests retain the connection IP established by ForwardedHeaders middleware.
/// Authenticated BFF requests with no single valid client IP share the unknown bucket.
/// </summary>
private string? GetEmailRequestSourceIp(HttpContext context)
{
var configuredSecret = bffForwardingOptions.Value.BffForwardingSecret;
var suppliedSecret = context.Request.Headers["X-Orbit-Bff-Secret"];
if (string.IsNullOrWhiteSpace(configuredSecret)
|| suppliedSecret.Count != 1
|| string.IsNullOrWhiteSpace(suppliedSecret[0])
|| !CryptographicOperations.FixedTimeEquals(
SHA256.HashData(Encoding.UTF8.GetBytes(configuredSecret)),
SHA256.HashData(Encoding.UTF8.GetBytes(suppliedSecret.ToString()))))
{
return context.GetClientIpAddress();
}

return $"ip:{context.GetClientIpAddress() ?? "unknown"}";
var suppliedIp = context.Request.Headers["X-Orbit-Client-Ip"];
if (suppliedIp.Count != 1 || !IPAddress.TryParse(suppliedIp[0], out var clientIp))
return null;

return (clientIp.IsIPv4MappedToIPv6 ? clientIp.MapToIPv4() : clientIp).ToString();
}

private static readonly HashSet<string> EmailPartitionedPolicies =
new(StringComparer.OrdinalIgnoreCase) { "auth", "waitlist" };

/// <summary>
/// Anonymous email requests consume both the per-account bucket and the policy's IP bucket.
/// The email ceiling limits attacks across sources; the IP ceiling limits email rotation
/// while allowing separate account buckets for callers behind a shared NAT. Either ceiling rejects.
/// </summary>
public static bool TryResolveEmailPartitionKey(
string policyName,
IEnumerable<object?> actionArguments,
Expand Down
3 changes: 3 additions & 0 deletions src/Orbit.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
}
},
"AllowedHosts": "*",
"ForwardedHeaders": {
"KnownNetworks": ["10.0.0.0/8"]
},
"ConnectionStrings": {
"DefaultConnection": "",
"SessionConnection": ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider
new Dictionary<string, RateLimitPolicy>(StringComparer.OrdinalIgnoreCase)
{
["auth"] = new(TimeSpan.FromMinutes(1), PermitLimit: 10, SegmentCount: 1),
["auth-ip"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 1),
["refresh"] = new(TimeSpan.FromMinutes(1), PermitLimit: 10, SegmentCount: 1),
["waitlist"] = new(TimeSpan.FromMinutes(10), PermitLimit: 5, SegmentCount: 1),
["waitlist-ip"] = new(TimeSpan.FromMinutes(10), PermitLimit: 15, SegmentCount: 1),
["chat"] = new(TimeSpan.FromMinutes(1), PermitLimit: 20, SegmentCount: 4),
["ai-resolve"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4),
["ai-operations"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4),
Expand Down
Loading
Loading