diff --git a/BACKLOG.md b/BACKLOG.md
index 242d409..cd44842 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -9,7 +9,7 @@ or `SPRINTS.md` are not committed backlog until they have a corresponding issue.
- [x] [#51 — Close the Python poller link-check DNS rebinding gap](https://github.com/CryptoJones/OSApplyTrack/issues/51)
- [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)
+- [x] [#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)
## Operations and scalability
diff --git a/README.md b/README.md
index e572caf..a8bfaf8 100644
--- a/README.md
+++ b/README.md
@@ -380,6 +380,10 @@ OSApplyTrack is built to face the public internet behind a reverse proxy:
than storing anything in the clear.
- **Rate limiting.** The magic-link and poll endpoints are per-IP fixed-window
rate-limited so the always-200 auth surface can't be abused for spam or probing.
+- **Bounded API input.** Ordinary JSON mutations are capped at 1 MiB, application
+ strings/notes have explicit length ceilings, and criteria/résumé collections
+ reject excessive cardinality with a clear `400` instead of growing rows or LLM
+ prompts without limit.
- **SSRF-hardened link probing.** The link prober refuses to connect to
private/loopback/link-local/reserved addresses and re-checks every redirect hop,
so a hostile listing URL can't pivot into your network.
diff --git a/api/ApplyTrack.Api.Tests/InputLimitTests.cs b/api/ApplyTrack.Api.Tests/InputLimitTests.cs
new file mode 100644
index 0000000..0caa900
--- /dev/null
+++ b/api/ApplyTrack.Api.Tests/InputLimitTests.cs
@@ -0,0 +1,223 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Aaron K. Clark
+
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using ApplyTrack.Api.Auth;
+using ApplyTrack.Api.Data;
+using ApplyTrack.Api.Middleware;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+
+namespace ApplyTrack.Api.Tests;
+
+/// Boundary and over-limit coverage for ordinary authenticated API input.
+[Collection(PostgresCollection.Name)]
+public class InputLimitTests : IAsyncLifetime
+{
+ private readonly PostgresFixture _pg;
+ private WebApplicationFactory _factory = null!;
+ private HttpClient _client = null!;
+
+ public InputLimitTests(PostgresFixture pg) => _pg = pg;
+
+ public async Task InitializeAsync()
+ {
+ _factory = new WebApplicationFactory().WithWebHostBuilder(b =>
+ b.UseSetting("ConnectionStrings:Postgres", _pg.ConnectionString));
+ var (_, sid) = await TestAuth.SeedSessionAsync(_pg.ConnectionString);
+ _client = _factory.CreateClient();
+ _client.DefaultRequestHeaders.Add("Cookie", $"{AuthCookie.Name}={sid}");
+ }
+
+ public async Task DisposeAsync()
+ {
+ _client.Dispose();
+ await _factory.DisposeAsync();
+ }
+
+ private static StringContent Json(string body) => new(body, Encoding.UTF8, "application/json");
+
+ private static StringContent Json(object body) =>
+ Json(JsonSerializer.Serialize(body));
+
+ private static async Task Detail(HttpResponseMessage response)
+ {
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
+ return document.RootElement.GetProperty("detail").GetString() ?? "";
+ }
+
+ private static StringContent SizedJson(int bytes)
+ {
+ const string prefix = "{\"padding\":\"";
+ const string suffix = "\"}";
+ var body = prefix + new string('x', bytes - prefix.Length - suffix.Length) + suffix;
+ Assert.Equal(bytes, Encoding.UTF8.GetByteCount(body));
+ return Json(body);
+ }
+
+ [Fact]
+ public async Task Json_body_accepts_the_exact_global_limit()
+ {
+ var response = await _client.PostAsync(
+ "/api/poll", SizedJson((int)JsonBodyLimitMiddleware.MaxBytes));
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Json_body_rejects_one_byte_over_with_detail()
+ {
+ var response = await _client.PostAsync(
+ "/api/poll", SizedJson((int)JsonBodyLimitMiddleware.MaxBytes + 1));
+
+ Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
+ Assert.Contains("1024 KiB", await Detail(response));
+ }
+
+ [Fact]
+ public async Task Application_accepts_field_and_notes_boundaries()
+ {
+ var response = await _client.PostAsync("/api/apps", Json(new
+ {
+ company = new string('c', InputLimits.Company),
+ role = "Engineer",
+ notes = new string('n', InputLimits.Notes),
+ }));
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("company")]
+ [InlineData("notes")]
+ public async Task Application_rejects_overlong_fields_with_detail(string field)
+ {
+ var payload = field == "company"
+ ? new { company = new string('c', InputLimits.Company + 1), notes = "" }
+ : new { company = "Acme", notes = new string('n', InputLimits.Notes + 1) };
+
+ var response = await _client.PostAsync("/api/apps", Json(payload));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Contains(field, await Detail(response));
+ }
+
+ [Fact]
+ public async Task Criteria_accepts_collection_boundaries()
+ {
+ var response = await _client.PutAsync("/api/criteria", Json(new
+ {
+ keywords = Enumerable.Range(0, InputLimits.Keywords).Select(i => $"keyword-{i}"),
+ exclude_locations = Enumerable.Range(0, InputLimits.ExcludedLocations)
+ .Select(i => $"location-{i}"),
+ ats_boards = Enumerable.Range(0, InputLimits.AtsBoards)
+ .Select(i => new { provider = "greenhouse", slug = $"company-{i}" }),
+ }));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("keywords")]
+ [InlineData("exclude_locations")]
+ [InlineData("ats_boards")]
+ public async Task Criteria_rejects_over_cardinality_with_detail(string field)
+ {
+ object payload = field switch
+ {
+ "keywords" => new
+ {
+ keywords = Enumerable.Range(0, InputLimits.Keywords + 1)
+ .Select(i => $"keyword-{i}"),
+ },
+ "exclude_locations" => new
+ {
+ exclude_locations = Enumerable.Range(0, InputLimits.ExcludedLocations + 1)
+ .Select(i => $"location-{i}"),
+ },
+ _ => new
+ {
+ ats_boards = Enumerable.Range(0, InputLimits.AtsBoards + 1)
+ .Select(i => new { provider = "lever", slug = $"company-{i}" }),
+ },
+ };
+
+ var response = await _client.PutAsync("/api/criteria", Json(payload));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Contains(field, await Detail(response));
+ }
+
+ [Fact]
+ public async Task Resume_accepts_collection_boundaries()
+ {
+ var response = await _client.PutAsync("/api/resume", Json(new
+ {
+ experience = Enumerable.Range(0, InputLimits.ResumeExperience).Select(i => new
+ {
+ company = $"Company {i}",
+ title = "Engineer",
+ highlights = Enumerable.Range(0, InputLimits.ResumeHighlights)
+ .Select(h => $"Highlight {h}"),
+ }),
+ skills = Enumerable.Range(0, InputLimits.ResumeSkills).Select(i => $"Skill {i}"),
+ certifications = Enumerable.Range(0, InputLimits.ResumeCertifications)
+ .Select(i => $"Certification {i}"),
+ links = Enumerable.Range(0, InputLimits.ResumeLinks)
+ .Select(i => new { label = $"Link {i}", url = $"https://example.com/{i}" }),
+ }));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("experience")]
+ [InlineData("highlights")]
+ [InlineData("skills")]
+ [InlineData("certifications")]
+ [InlineData("links")]
+ public async Task Resume_rejects_over_cardinality_with_detail(string field)
+ {
+ object payload = field switch
+ {
+ "experience" => new
+ {
+ experience = Enumerable.Range(0, InputLimits.ResumeExperience + 1)
+ .Select(i => new { company = $"Company {i}" }),
+ },
+ "highlights" => new
+ {
+ experience = new[]
+ {
+ new
+ {
+ company = "Acme",
+ highlights = Enumerable.Range(0, InputLimits.ResumeHighlights + 1)
+ .Select(i => $"Highlight {i}"),
+ },
+ },
+ },
+ "skills" => new
+ {
+ skills = Enumerable.Range(0, InputLimits.ResumeSkills + 1)
+ .Select(i => $"Skill {i}"),
+ },
+ "certifications" => new
+ {
+ certifications = Enumerable.Range(0, InputLimits.ResumeCertifications + 1)
+ .Select(i => $"Certification {i}"),
+ },
+ _ => new
+ {
+ links = Enumerable.Range(0, InputLimits.ResumeLinks + 1)
+ .Select(i => new { url = $"https://example.com/{i}" }),
+ },
+ };
+
+ var response = await _client.PutAsync("/api/resume", Json(payload));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Contains(field, await Detail(response));
+ }
+}
diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj
index 2c7c15a..36c6df5 100644
--- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj
+++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj
@@ -5,7 +5,7 @@
enable
enable
ApplyTrack.Api
- 1.11.3
+ 1.11.4
Aaron K. Clark
Copyright 2026 Aaron K. Clark
Apache-2.0
diff --git a/api/ApplyTrack.Api/Data/AppFields.cs b/api/ApplyTrack.Api/Data/AppFields.cs
index 385ba61..c2c8465 100644
--- a/api/ApplyTrack.Api/Data/AppFields.cs
+++ b/api/ApplyTrack.Api/Data/AppFields.cs
@@ -39,7 +39,7 @@ public AppFields Normalized()
static string S(string? v) => (v ?? "").Trim();
var lane = S(Lane).ToLowerInvariant();
var status = S(Status).ToLowerInvariant();
- return this with
+ var normalized = this with
{
Company = S(Company),
Role = S(Role),
@@ -57,5 +57,7 @@ public AppFields Normalized()
Score = S(Score),
Notes = (Notes ?? "").TrimEnd(),
};
+ InputLimits.ValidateApplication(normalized);
+ return normalized;
}
}
diff --git a/api/ApplyTrack.Api/Data/ApplicationRepo.cs b/api/ApplyTrack.Api/Data/ApplicationRepo.cs
index 1b83ee7..0fc0cac 100644
--- a/api/ApplyTrack.Api/Data/ApplicationRepo.cs
+++ b/api/ApplyTrack.Api/Data/ApplicationRepo.cs
@@ -233,8 +233,11 @@ ON CONFLICT (tenant_id, name) DO NOTHING
public Task UpdateStructuredAsync(string name, AppFields fields, string? expectedVersion) =>
DoUpdateAsync(name, fields.Normalized(), expectedVersion);
- public Task UpdateRawAsync(string name, string content, string? expectedVersion) =>
- DoUpdateAsync(name, MarkdownCodec.Parse(content), expectedVersion);
+ public Task UpdateRawAsync(string name, string content, string? expectedVersion)
+ {
+ InputLimits.Text("content", content, InputLimits.RawApplication);
+ return DoUpdateAsync(name, MarkdownCodec.Parse(content).Normalized(), expectedVersion);
+ }
public async Task DeleteAsync(string name)
{
diff --git a/api/ApplyTrack.Api/Data/Criteria.cs b/api/ApplyTrack.Api/Data/Criteria.cs
index 0705844..e242387 100644
--- a/api/ApplyTrack.Api/Data/Criteria.cs
+++ b/api/ApplyTrack.Api/Data/Criteria.cs
@@ -61,6 +61,7 @@ public static Dictionary DefaultSources() =>
/// Build a normalized Criteria from loose JSON, ignoring junk keys (heir to from_dict).
public static Criteria FromJson(JsonElement data)
{
+ InputLimits.ValidateCriteria(data);
var lane = GetString(data, "default_lane", "ai").Trim().ToLowerInvariant();
var score = ScoreDefault;
diff --git a/api/ApplyTrack.Api/Data/InputLimits.cs b/api/ApplyTrack.Api/Data/InputLimits.cs
new file mode 100644
index 0000000..6839a3a
--- /dev/null
+++ b/api/ApplyTrack.Api/Data/InputLimits.cs
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Aaron K. Clark
+
+using System.Text.Json;
+
+namespace ApplyTrack.Api.Data;
+
+///
+/// Human-facing API limits. These bound persisted rows and LLM prompt material at
+/// the model edge; request-byte limits are enforced separately by middleware.
+///
+public static class InputLimits
+{
+ public const int Company = 256;
+ public const int Role = 256;
+ public const int Lane = 32;
+ public const int Status = 32;
+ public const int Link = 2048;
+ public const int ApplicationField = 512;
+ public const int ContactEmail = 320;
+ public const int ApplicationMetadata = 128;
+ public const int Notes = 64 * 1024;
+ public const int RawApplication = Notes + 8 * 1024;
+
+ public const int Keywords = 100;
+ public const int Keyword = 100;
+ public const int ExcludedLocations = 100;
+ public const int ExcludedLocation = 200;
+ public const int AtsBoards = 50;
+ public const int AtsSlug = 200;
+
+ public const int ResumeExperience = 50;
+ public const int ResumeHighlights = 50;
+ public const int ResumeSkills = 200;
+ public const int ResumeCertifications = 100;
+ public const int ResumeLinks = 50;
+ public const int ResumeHeading = 300;
+ public const int ResumeSummary = 256 * 1024;
+ public const int ResumeItem = 2000;
+
+ public const int LlmBaseUrl = 2048;
+ public const int LlmModel = 200;
+ public const int LlmApiKey = 8192;
+ public const int CoverLetterSignature = 16 * 1024;
+
+ public static void ValidateApplication(AppFields fields)
+ {
+ Text("company", fields.Company, Company);
+ Text("role", fields.Role, Role);
+ Text("lane", fields.Lane, Lane);
+ Text("status", fields.Status, Status);
+ Text("link", fields.Link, Link);
+ Text("location", fields.Location, ApplicationField);
+ Text("salary", fields.Salary, ApplicationField);
+ Text("source", fields.Source, ApplicationField);
+ Text("contact", fields.Contact, ApplicationField);
+ Text("contact_email", fields.ContactEmail, ContactEmail);
+ Text("applied", fields.Applied, ApplicationMetadata);
+ Text("followup", fields.Followup, ApplicationMetadata);
+ Text("created", fields.Created, ApplicationMetadata);
+ Text("score", fields.Score, ApplicationMetadata);
+ Text("notes", fields.Notes, Notes);
+ }
+
+ public static void ValidateCriteria(JsonElement data)
+ {
+ Array(data, "keywords", Keywords, Keyword);
+ Array(data, "exclude_locations", ExcludedLocations, ExcludedLocation);
+
+ if (data.ValueKind != JsonValueKind.Object
+ || !data.TryGetProperty("ats_boards", out var boards)
+ || boards.ValueKind != JsonValueKind.Array)
+ return;
+
+ Count("ats_boards", boards.GetArrayLength(), AtsBoards);
+ foreach (var board in boards.EnumerateArray())
+ {
+ if (board.ValueKind != JsonValueKind.Object) continue;
+ JsonText(board, "provider", Keyword);
+ JsonText(board, "slug", AtsSlug);
+ }
+ }
+
+ public static void ValidateResume(JsonElement data)
+ {
+ JsonText(data, "full_name", ResumeHeading);
+ JsonText(data, "headline", ResumeHeading);
+ JsonText(data, "location", ResumeHeading);
+ JsonText(data, "summary", ResumeSummary);
+ Array(data, "skills", ResumeSkills, ResumeItem);
+ Array(data, "certifications", ResumeCertifications, ResumeItem);
+
+ if (data.ValueKind != JsonValueKind.Object)
+ return;
+
+ if (data.TryGetProperty("experience", out var experience)
+ && experience.ValueKind == JsonValueKind.Array)
+ {
+ Count("experience", experience.GetArrayLength(), ResumeExperience);
+ foreach (var entry in experience.EnumerateArray())
+ {
+ if (entry.ValueKind != JsonValueKind.Object) continue;
+ JsonText(entry, "company", ResumeHeading);
+ JsonText(entry, "title", ResumeHeading);
+ JsonText(entry, "dates", ResumeHeading);
+ Array(entry, "highlights", ResumeHighlights, ResumeItem);
+ }
+ }
+
+ if (data.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array)
+ {
+ Count("links", links.GetArrayLength(), ResumeLinks);
+ foreach (var entry in links.EnumerateArray())
+ {
+ if (entry.ValueKind != JsonValueKind.Object) continue;
+ JsonText(entry, "label", ResumeHeading);
+ JsonText(entry, "url", Link);
+ }
+ }
+ }
+
+ public static string Text(string field, string? value, int maximum)
+ {
+ var text = value ?? "";
+ if (text.Length > maximum)
+ throw new AppValidationException(
+ $"field '{field}' exceeds the maximum length of {maximum} characters");
+ return text;
+ }
+
+ public static void Count(string field, int count, int maximum)
+ {
+ if (count > maximum)
+ throw new AppValidationException(
+ $"field '{field}' accepts at most {maximum} items");
+ }
+
+ private static void Array(JsonElement data, string field, int maximum, int itemMaximum)
+ {
+ if (data.ValueKind != JsonValueKind.Object
+ || !data.TryGetProperty(field, out var array)
+ || array.ValueKind != JsonValueKind.Array)
+ return;
+
+ Count(field, array.GetArrayLength(), maximum);
+ foreach (var item in array.EnumerateArray())
+ {
+ var value = item.ValueKind == JsonValueKind.String
+ ? item.GetString() ?? ""
+ : item.ToString();
+ Text($"{field} item", value, itemMaximum);
+ }
+ }
+
+ private static void JsonText(JsonElement data, string field, int maximum)
+ {
+ if (data.ValueKind != JsonValueKind.Object
+ || !data.TryGetProperty(field, out var value))
+ return;
+ Text(field, value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : value.ToString(), maximum);
+ }
+}
diff --git a/api/ApplyTrack.Api/Data/Resume.cs b/api/ApplyTrack.Api/Data/Resume.cs
index f3b567a..b7e5124 100644
--- a/api/ApplyTrack.Api/Data/Resume.cs
+++ b/api/ApplyTrack.Api/Data/Resume.cs
@@ -39,6 +39,7 @@ public sealed class Resume
/// Build a normalized Resume from loose JSON, ignoring junk keys (mirrors Criteria.FromJson).
public static Resume FromJson(JsonElement data)
{
+ InputLimits.ValidateResume(data);
return new Resume
{
FullName = GetString(data, "full_name"),
diff --git a/api/ApplyTrack.Api/Endpoints/BlacklistEndpoints.cs b/api/ApplyTrack.Api/Endpoints/BlacklistEndpoints.cs
index e0e3c5f..d8dd530 100644
--- a/api/ApplyTrack.Api/Endpoints/BlacklistEndpoints.cs
+++ b/api/ApplyTrack.Api/Endpoints/BlacklistEndpoints.cs
@@ -25,6 +25,7 @@ public static void MapBlacklistEndpoints(this IEndpointRouteBuilder app)
var company = (payload.Company ?? "").Trim();
if (company.Length == 0)
throw new AppValidationException("company is required");
+ InputLimits.Text("company", company, InputLimits.Company);
var added = await bl.AddAsync(company);
var passed = await bl.PassOpenLeadsAsync(company);
return Results.Ok(new { company, added, passed });
diff --git a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs
index 7432abb..23f2198 100644
--- a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs
+++ b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs
@@ -85,6 +85,8 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app)
{
var baseUrl = GetString(payload, "base_url");
var model = GetString(payload, "model");
+ InputLimits.Text("base_url", baseUrl, InputLimits.LlmBaseUrl);
+ InputLimits.Text("model", model, InputLimits.LlmModel);
LlmEndpointPolicy.ValidateTenantBaseUrl(baseUrl);
// Distinguish "api_key omitted" (leave the stored key alone) from
@@ -92,6 +94,8 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app)
var changeKey = payload.ValueKind == JsonValueKind.Object
&& payload.TryGetProperty("api_key", out _);
var newKey = changeKey ? GetString(payload, "api_key") : null;
+ if (newKey is not null)
+ InputLimits.Text("api_key", newKey, InputLimits.LlmApiKey);
// Same omitted-means-keep semantics for the cover-letter toggle.
bool? lettersEnabled = payload.ValueKind == JsonValueKind.Object
@@ -105,6 +109,9 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app)
&& sig.ValueKind == JsonValueKind.String
? sig.GetString()?.Trim()
: null;
+ if (signature is not null)
+ InputLimits.Text(
+ "cover_letter_signature", signature, InputLimits.CoverLetterSignature);
await repo.UpsertAsync(baseUrl, model, changeKey, newKey, lettersEnabled, signature);
diff --git a/api/ApplyTrack.Api/Middleware/JsonBodyLimitMiddleware.cs b/api/ApplyTrack.Api/Middleware/JsonBodyLimitMiddleware.cs
new file mode 100644
index 0000000..6ef0997
--- /dev/null
+++ b/api/ApplyTrack.Api/Middleware/JsonBodyLimitMiddleware.cs
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Aaron K. Clark
+
+using Microsoft.AspNetCore.Http.Features;
+
+namespace ApplyTrack.Api.Middleware;
+
+///
+/// Caps ordinary JSON mutations before model binding. Large account imports and
+/// multipart résumé uploads retain their purpose-built limits.
+///
+public sealed class JsonBodyLimitMiddleware(RequestDelegate next)
+{
+ public const long MaxBytes = 1024 * 1024;
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (!ShouldLimit(context.Request))
+ {
+ await next(context);
+ return;
+ }
+
+ var feature = context.Features.Get();
+ if (feature is { IsReadOnly: false })
+ feature.MaxRequestBodySize = MaxBytes;
+
+ if (context.Request.ContentLength > MaxBytes)
+ {
+ await Reject(context);
+ return;
+ }
+
+ try
+ {
+ await next(context);
+ }
+ catch (BadHttpRequestException ex) when (
+ ex.StatusCode == StatusCodes.Status413RequestEntityTooLarge
+ && !context.Response.HasStarted)
+ {
+ await Reject(context);
+ }
+ }
+
+ private static bool ShouldLimit(HttpRequest request) =>
+ request.Path.StartsWithSegments("/api")
+ && request.Path != "/api/account/import"
+ && request.Method is "POST" or "PUT" or "PATCH"
+ && request.ContentType?.StartsWith(
+ "application/json", StringComparison.OrdinalIgnoreCase) == true;
+
+ private static async Task Reject(HttpContext context)
+ {
+ context.Response.Clear();
+ context.Response.StatusCode = StatusCodes.Status413RequestEntityTooLarge;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ detail = $"JSON request body exceeds the {MaxBytes / 1024} KiB limit",
+ });
+ }
+}
diff --git a/api/ApplyTrack.Api/Program.cs b/api/ApplyTrack.Api/Program.cs
index d578c9c..05b1a40 100644
--- a/api/ApplyTrack.Api/Program.cs
+++ b/api/ApplyTrack.Api/Program.cs
@@ -192,6 +192,10 @@ static string ClientPartition(HttpContext ctx) =>
// get the FastAPI-compatible {"detail": "..."} body + status the SPA expects.
app.UseMiddleware();
+// Bound ordinary JSON mutations before Minimal API model binding. Account import
+// and résumé PDF upload keep their separate, larger purpose-built limits.
+app.UseMiddleware();
+
// Serve the vanilla-JS SPA verbatim from wwwroot (index.html as the default doc).
// Static files short-circuit before the tenancy middleware, so the shell loads
// without a session and the SPA's own login gate handles the 401s on /api.
diff --git a/pyproject.toml b/pyproject.toml
index 673822c..0f0d04e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "applytrack-poller"
-version = "1.11.3"
+version = "1.11.4"
description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres."
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
diff --git a/uv.lock b/uv.lock
index b4000b0..051528d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -23,7 +23,7 @@ wheels = [
[[package]]
name = "applytrack-poller"
-version = "1.11.3"
+version = "1.11.4"
source = { editable = "." }
dependencies = [
{ name = "defusedxml" },