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
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep #50 unchecked until the issue is closed.

The linked issue is currently open, but line 12 marks it complete, contrary to this file’s stated rule. (github.com)

🤖 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 `@BACKLOG.md` at line 12, Update the `#50` entry in BACKLOG.md from checked to
unchecked status, keeping the existing issue link and description unchanged
until the linked issue is closed.

- [ ] [#52 — Serialize overlapping tenant poll runs](https://github.com/CryptoJones/OSApplyTrack/issues/52)

## Operations and scalability
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +383 to +386

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the 413 body-limit response.

The 1 MiB JSON cap returns 413, while field and collection validation returns 400. State this distinction here so API consumers can handle oversized bodies correctly.

🤖 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 `@README.md` around lines 383 - 386, Update the “Bounded API input”
documentation in README.md to explicitly state that requests exceeding the 1 MiB
JSON body limit return HTTP 413, while field-length and collection-cardinality
validation errors return HTTP 400.

- **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.
Expand Down
223 changes: 223 additions & 0 deletions api/ApplyTrack.Api.Tests/InputLimitTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Boundary and over-limit coverage for ordinary authenticated API input.</summary>
[Collection(PostgresCollection.Name)]
public class InputLimitTests : IAsyncLifetime
{
private readonly PostgresFixture _pg;
private WebApplicationFactory<Program> _factory = null!;
private HttpClient _client = null!;

public InputLimitTests(PostgresFixture pg) => _pg = pg;

public async Task InitializeAsync()
{
_factory = new WebApplicationFactory<Program>().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<string> 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));
}
Comment on lines +60 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the endpoint-only limit checks.

Add over-limit HTTP tests for /api/blacklist and /api/llm-settings; this suite currently does not exercise the validators added in BlacklistEndpoints and MaterialsEndpoints, including their expected 400 detail responses.

🤖 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.Tests/InputLimitTests.cs` around lines 60 - 222, Add
endpoint-level over-limit tests to this suite for POST /api/blacklist and the
relevant /api/llm-settings operation, targeting the validators in
BlacklistEndpoints and MaterialsEndpoints. Build payloads exceeding each
endpoint’s configured limits, assert HTTP 400 responses, and verify the response
detail identifies the rejected field or limit.

}
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.3</Version>
<Version>1.11.4</Version>
<Authors>Aaron K. Clark</Authors>
<Copyright>Copyright 2026 Aaron K. Clark</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
4 changes: 3 additions & 1 deletion api/ApplyTrack.Api/Data/AppFields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -57,5 +57,7 @@ public AppFields Normalized()
Score = S(Score),
Notes = (Notes ?? "").TrimEnd(),
};
InputLimits.ValidateApplication(normalized);
return normalized;
}
}
7 changes: 5 additions & 2 deletions api/ApplyTrack.Api/Data/ApplicationRepo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,11 @@ ON CONFLICT (tenant_id, name) DO NOTHING
public Task<string> UpdateStructuredAsync(string name, AppFields fields, string? expectedVersion) =>
DoUpdateAsync(name, fields.Normalized(), expectedVersion);

public Task<string> UpdateRawAsync(string name, string content, string? expectedVersion) =>
DoUpdateAsync(name, MarkdownCodec.Parse(content), expectedVersion);
public Task<string> 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)
{
Expand Down
1 change: 1 addition & 0 deletions api/ApplyTrack.Api/Data/Criteria.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public static Dictionary<string, bool> DefaultSources() =>
/// <summary>Build a normalized Criteria from loose JSON, ignoring junk keys (heir to from_dict).</summary>
public static Criteria FromJson(JsonElement data)
{
InputLimits.ValidateCriteria(data);
var lane = GetString(data, "default_lane", "ai").Trim().ToLowerInvariant();

var score = ScoreDefault;
Expand Down
Loading
Loading