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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ API_PORT=8080
# Belt-and-suspenders: also pin the host(s) Kestrel will answer for, so forged
# Host headers are rejected outright. Overrides the appsettings.json "*" default.
# AllowedHosts=apply.example.com
#
# Forwarded client/protocol headers are accepted only from a trusted proxy.
# Same-host loopback proxies need no configuration. For a proxy container or
# remote load balancer, set its exact source IP or CIDR network (not the public
# visitor addresses). Never use 0.0.0.0/0 or ::/0.
# FORWARDED_HEADERS_KNOWN_PROXY=172.17.0.1
# FORWARDED_HEADERS_KNOWN_NETWORK=172.18.0.0/16

# Poller cadence (seconds). The poller container drains the on-demand poll queue every
# DRAIN_INTERVAL and runs a full multi-tenant poll every POLL_INTERVAL.
Expand Down
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ or `SPRINTS.md` are not committed backlog until they have a corresponding issue.
## Security and stability

- [x] [#51 — Close the Python poller link-check DNS rebinding gap](https://github.com/CryptoJones/OSApplyTrack/issues/51)
- [ ] [#49 — Restrict forwarded-header trust to configured proxies](https://github.com/CryptoJones/OSApplyTrack/issues/49)
- [x] [#49 — Restrict forwarded-header trust to configured proxies](https://github.com/CryptoJones/OSApplyTrack/issues/49)
- [ ] [#50 — Add global JSON body caps and per-field/cardinality limits](https://github.com/CryptoJones/OSApplyTrack/issues/50)
- [ ] [#52 — Serialize overlapping tenant poll runs](https://github.com/CryptoJones/OSApplyTrack/issues/52)

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ All configuration is environment variables (see [`.env.example`](./.env.example)
| `POLL_INTERVAL` | `3600` | Seconds between full multi-tenant polls. |
| `ConnectionStrings__Postgres` | _(compose default)_ | Override to point the API at an external Postgres. |
| `DATABASE_URL` | _(compose default)_ | Override to point the poller at an external Postgres (libpq URL). |
| `FORWARDED_HEADERS_KNOWN_PROXY` / `FORWARDED_HEADERS_KNOWN_NETWORK` | _(empty)_ | Source IP or CIDR of a trusted reverse proxy when it is not on loopback. |
| `APPLYTRACK_DIR` | `./applications` | Default folder the `import-md` command reads when `--dir` is omitted. |
| `Llm__BaseUrl` / `Llm__Model` / `Llm__ApiKey` | _(empty)_ | Instance-default cover-letter LLM — any OpenAI-compatible endpoint (a local Ollama/vLLM/LM Studio model or a hosted provider). `ApiKey` is blank for a keyless local model. Each tenant can override these in **Settings · AI**, including a reusable multi-line signature. See [Cover letters](#cover-letters). |
| `APPLYTRACK_SECRETS_KEY` | _(empty)_ | Master key (AES-256-GCM) that encrypts each tenant's **own** stored LLM API key at rest. Leave unset to disable per-tenant keys — the instance default above is still used. |
Expand Down Expand Up @@ -383,8 +384,12 @@ OSApplyTrack is built to face the public internet behind a reverse proxy:
private/loopback/link-local/reserved addresses and re-checks every redirect hop,
so a hostile listing URL can't pivot into your network.
- **Behind HTTPS.** Front the API with a TLS-terminating reverse proxy (Caddy,
nginx, or `tailscale serve`). The API honors `X-Forwarded-Proto`, so the session
cookie's `Secure` flag is set automatically. Don't expose Kestrel directly.
nginx, or `tailscale serve`). Same-host loopback proxies are trusted by default.
For a proxy container or remote load balancer, set
`FORWARDED_HEADERS_KNOWN_PROXY` to its source IP or
`FORWARDED_HEADERS_KNOWN_NETWORK` to its CIDR. Forwarded headers from every other
address are ignored, so a direct caller cannot forge its rate-limit IP or HTTPS
state. Don't expose Kestrel directly.
- **Change the default password.** For any deployment reachable beyond `localhost`,
change `POSTGRES_PASSWORD` (and the matching connection string) from the
bundled development default before first boot — the documented value is not a
Expand Down
48 changes: 48 additions & 0 deletions api/ApplyTrack.Api.Tests/ConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using System.Net;

namespace ApplyTrack.Api.Tests;

Expand Down Expand Up @@ -56,4 +58,50 @@ public async Task App_boots_with_custom_MigrationTimeoutSeconds()
var response = await client.GetAsync("/health");
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
}

[Fact]
public void Forwarded_headers_keep_safe_loopback_defaults()
{
var configuration = new ConfigurationBuilder().Build();
var options = ForwardedHeadersConfiguration.Create(configuration);

Assert.Equal(1, options.ForwardLimit);
Assert.NotEmpty(options.KnownProxies);
Assert.DoesNotContain(IPAddress.Any, options.KnownProxies);
Assert.DoesNotContain(IPAddress.IPv6Any, options.KnownProxies);
}

[Fact]
public void Forwarded_headers_add_configured_proxy_and_network()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ForwardedHeaders:KnownProxies:0"] = "192.0.2.10",
["ForwardedHeaders:KnownNetworks:0"] = "198.51.100.0/24",
})
.Build();

var options = ForwardedHeadersConfiguration.Create(configuration);

Assert.Contains(IPAddress.Parse("192.0.2.10"), options.KnownProxies);
Assert.Contains(IPNetwork.Parse("198.51.100.0/24"), options.KnownIPNetworks);
}

[Theory]
[InlineData("ForwardedHeaders:KnownProxies:0", "not-an-ip")]
[InlineData("ForwardedHeaders:KnownNetworks:0", "192.0.2.1/not-a-prefix")]
public void Forwarded_headers_reject_invalid_configuration(string key, string value)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { [key] = value })
.Build();

var error = Assert.Throws<InvalidOperationException>(() =>
{
_ = ForwardedHeadersConfiguration.Create(configuration);
});

Assert.Contains(value, error.Message);
}
}
69 changes: 56 additions & 13 deletions api/ApplyTrack.Api.Tests/RateLimitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
using System.Net;
using System.Text;
using ApplyTrack.Api.Auth;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;

namespace ApplyTrack.Api.Tests;

/// <summary>
/// Asserts that the rate-limit partition key uses the client IP from forwarded
/// headers (via UseForwardedHeaders → RemoteIpAddress) rather than raw header
/// parsing, and that it falls back to the direct connection IP otherwise.
/// Asserts that untrusted direct requests cannot replace the rate-limit partition
/// with X-Forwarded-For, while explicitly trusted proxies retain per-client buckets.
/// Each test boots a fresh factory so rate-limit state is unshared.
/// </summary>
[Collection(PostgresCollection.Name)]
Expand All @@ -25,20 +27,31 @@ public class RateLimitTests : IAsyncLifetime

public Task InitializeAsync()
{
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("ConnectionStrings:Postgres", _pg.ConnectionString));
_factory = CreateFactory(trustProxy: false);
return Task.CompletedTask;
}

public async Task DisposeAsync() => await _factory.DisposeAsync();

private static StringContent Json(string body) => new(body, Encoding.UTF8, "application/json");

private WebApplicationFactory<Program> CreateFactory(bool trustProxy) =>
new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseSetting("ConnectionStrings:Postgres", _pg.ConnectionString);
if (trustProxy)
b.UseSetting("ForwardedHeaders:KnownProxies:0", "192.0.2.10");
b.ConfigureTestServices(services =>
services.AddSingleton<IStartupFilter>(
new RemoteIpStartupFilter(IPAddress.Parse("192.0.2.10"))));
});

/// <summary>Seeds a fresh session and returns an authenticated client.</summary>
private async Task<HttpClient> AuthenticatedClient()
private async Task<HttpClient> AuthenticatedClient(
WebApplicationFactory<Program>? factory = null)
{
var (_, sid) = await TestAuth.SeedSessionAsync(_pg.ConnectionString);
var client = _factory.CreateClient();
var client = (factory ?? _factory).CreateClient();
client.DefaultRequestHeaders.Add("Cookie", $"{AuthCookie.Name}={sid}");
return client;
}
Expand Down Expand Up @@ -91,11 +104,8 @@ public async Task Rate_limit_without_forwarded_header_uses_remote_ip()
}

[Fact]
public async Task Distinct_forwarded_ips_get_independent_rate_limit_buckets()
public async Task Distinct_spoofed_forwarded_ips_share_the_direct_connection_bucket()
{
// The point of the PR: the partition key is the *forwarded* client IP, so
// exhausting one client's budget must not throttle a different client behind
// the same proxy. Same authenticated session, two different X-Forwarded-For.
var client = await AuthenticatedClient();

client.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.1");
Expand All @@ -107,11 +117,44 @@ public async Task Distinct_forwarded_ips_get_independent_rate_limit_buckets()
var exhausted = await client.PostAsync("/api/poll", Json("{}"));
Assert.Equal(HttpStatusCode.TooManyRequests, exhausted.StatusCode);

// A request forwarded from a different client IP lands in a separate bucket
// and is still allowed — proving partitioning is per forwarded IP, not global.
client.DefaultRequestHeaders.Remove("X-Forwarded-For");
client.DefaultRequestHeaders.Add("X-Forwarded-For", "198.51.100.2");
var other = await client.PostAsync("/api/poll", Json("{}"));
Assert.Equal(HttpStatusCode.TooManyRequests, other.StatusCode);
}

[Fact]
public async Task Distinct_forwarded_ips_from_a_configured_proxy_get_independent_buckets()
{
await using var trustedFactory = CreateFactory(trustProxy: true);
var client = await AuthenticatedClient(trustedFactory);

client.DefaultRequestHeaders.Add("X-Forwarded-For", "203.0.113.1");
for (var i = 0; i < 15; i++)
{
var res = await client.PostAsync("/api/poll", Json("{}"));
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
var exhausted = await client.PostAsync("/api/poll", Json("{}"));
Assert.Equal(HttpStatusCode.TooManyRequests, exhausted.StatusCode);

client.DefaultRequestHeaders.Remove("X-Forwarded-For");
client.DefaultRequestHeaders.Add("X-Forwarded-For", "198.51.100.2");
var other = await client.PostAsync("/api/poll", Json("{}"));
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
}

private sealed class RemoteIpStartupFilter(IPAddress remoteIp) : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) =>
app =>
{
app.Use(async (context, nextMiddleware) =>
{
context.Connection.RemoteIpAddress = remoteIp;
await nextMiddleware();
});
next(app);
};
}
}
2 changes: 1 addition & 1 deletion api/ApplyTrack.Api/ApplyTrack.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ApplyTrack.Api</RootNamespace>
<Version>1.11.2</Version>
<Version>1.11.3</Version>
<Authors>Aaron K. Clark</Authors>
<Copyright>Copyright 2026 Aaron K. Clark</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
48 changes: 48 additions & 0 deletions api/ApplyTrack.Api/ForwardedHeadersConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark

using System.Net;
using Microsoft.AspNetCore.HttpOverrides;

namespace ApplyTrack.Api;

/// <summary>
/// Builds a fail-closed forwarded-header policy. ASP.NET Core's loopback defaults
/// remain trusted for same-host proxies; every other proxy or network must be named
/// explicitly in configuration.
/// </summary>
public static class ForwardedHeadersConfiguration
{
public static ForwardedHeadersOptions Create(IConfiguration configuration)
{
var options = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
ForwardLimit = 1,
};

foreach (var value in Values(configuration, "ForwardedHeaders:KnownProxies"))
{
if (!IPAddress.TryParse(value, out var address))
throw new InvalidOperationException(
$"ForwardedHeaders:KnownProxies contains invalid IP address '{value}'.");
options.KnownProxies.Add(address);
}

foreach (var value in Values(configuration, "ForwardedHeaders:KnownNetworks"))
{
if (!System.Net.IPNetwork.TryParse(value, out var network))
throw new InvalidOperationException(
$"ForwardedHeaders:KnownNetworks contains invalid CIDR network '{value}'.");
options.KnownIPNetworks.Add(network);
}
Comment on lines +32 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline api/ApplyTrack.Api/ForwardedHeadersConfiguration.cs --view expanded || true

printf '\n== Relevant file contents ==\n'
cat -n api/ApplyTrack.Api/ForwardedHeadersConfiguration.cs | sed -n '1,220p'

printf '\n== Search for KnownNetworks / prefix-length handling ==\n'
rg -n "KnownNetworks|KnownIPNetworks|PrefixLength|0\.0\.0\.0/0|::/0|ForwardedHeaders" api README.md . -g '!**/bin/**' -g '!**/obj/**' || true

Repository: CryptoJones/OSApplyTrack

Length of output: 9213


Reject wildcard CIDR ranges in ForwardedHeaders:KnownNetworks. IPNetwork.TryParse accepts 0.0.0.0/0 and ::/0, which would trust every client as a proxy and reopen the spoofing hole this change is meant to close. Add an explicit zero-prefix check and fail fast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/ApplyTrack.Api/ForwardedHeadersConfiguration.cs` around lines 32 - 38,
Update the KnownNetworks parsing loop in ForwardedHeadersConfiguration to reject
successfully parsed networks whose prefix length is zero, including 0.0.0.0/0
and ::/0. Throw the same configuration validation exception before adding such
networks to options.KnownIPNetworks, while preserving acceptance of non-wildcard
valid CIDR ranges.


return options;
}

private static IEnumerable<string> Values(IConfiguration configuration, string section) =>
configuration.GetSection(section).GetChildren()
.Select(item => item.Value?.Trim())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!);
}
18 changes: 6 additions & 12 deletions api/ApplyTrack.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,12 @@ static string ClientPartition(HttpContext ctx) =>
builder.Configuration["MigrationTimeoutSeconds"], defaultValue: 60));
Migrator.Upgrade(connectionString, migrationTimeout);

// Behind a TLS-terminating reverse proxy (Caddy/nginx/`tailscale serve` — the usual
// self-host front), honor X-Forwarded-Proto so Request.IsHttps is true and the session
// cookie keeps its Secure flag. The app is meant to sit behind that proxy, so forwarded
// headers are accepted from any hop — don't expose Kestrel directly to the internet.
// Plain-HTTP local/dev sends no such header, so this is a no-op there.
var forwarded = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor,
};
forwarded.KnownIPNetworks.Clear();
forwarded.KnownProxies.Clear();
app.UseForwardedHeaders(forwarded);
// Honor forwarded client/protocol data only from a known reverse proxy. ASP.NET
// Core's loopback defaults cover same-host Caddy/nginx/`tailscale serve`; container
// gateways and remote proxies must be named under ForwardedHeaders:KnownProxies or
// KnownNetworks. An untrusted direct caller's headers are ignored, so they cannot
// forge Request.IsHttps or rotate the per-IP rate-limit partition.
app.UseForwardedHeaders(ForwardedHeadersConfiguration.Create(builder.Configuration));

// Enforce request-body size limits on the two endpoints that accept large uploads,
// checked on Content-Length before the body is read, so it works under TestServer too.
Expand Down
4 changes: 4 additions & 0 deletions api/ApplyTrack.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
}
},
"AllowedHosts": "*",
"ForwardedHeaders": {
"KnownProxies": [],
"KnownNetworks": []
},
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=applytrack;Username=applytrack;Password=JanewayDidNothingWrong"
}
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ services:
Email__Password: ${Email__Password:-}
Email__From: ${Email__From:-}
Email__FromName: ${Email__FromName:-OSApplyTrack}
# Same-host proxies are trusted by default. Set either value only when the
# proxy reaches this container from a different address or bridge network.
ForwardedHeaders__KnownProxies__0: ${FORWARDED_HEADERS_KNOWN_PROXY:-}
ForwardedHeaders__KnownNetworks__0: ${FORWARDED_HEADERS_KNOWN_NETWORK:-}
# 8080 is contested (vLLM, llama.cpp, and half of all dev tools default to it).
# If it's already taken, Docker fails the bind loudly ("address already in use")
# rather than silently winning — relocate by setting API_PORT in .env.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "applytrack-poller"
version = "1.11.2"
version = "1.11.3"
description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres."
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

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

Loading