fix(security): harden public CV edges
CI and Deploy / test (pull_request) Failing after 5m23s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 20:53:16 +02:00
parent 74a1e0d845
commit 8ef8b098c8
13 changed files with 54 additions and 6 deletions
+1
View File
@@ -50,6 +50,7 @@ tmp/
# Runtime data that must never be committed (DataProtection keys, exports, CV artifacts)
keys/
**/.dev-auth-token.txt
backups/
JobTrackerApi/exports/
JobTrackerApi/CvArtifacts/
@@ -0,0 +1,29 @@
using System.Net;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class RateLimitPartitionKeysTests
{
[Fact]
public void Public_pdf_partition_isolated_by_client_and_slug()
{
var first = Context("203.0.113.10", "shared-slug");
var second = Context("203.0.113.11", "shared-slug");
var otherSlug = Context("203.0.113.10", "other-slug");
Assert.NotEqual(RateLimitPartitionKeys.PublicPdf(first), RateLimitPartitionKeys.PublicPdf(second));
Assert.NotEqual(RateLimitPartitionKeys.PublicPdf(first), RateLimitPartitionKeys.PublicPdf(otherSlug));
Assert.Equal(RateLimitPartitionKeys.PublicPdf(first), RateLimitPartitionKeys.PublicPdf(Context("203.0.113.10", "shared-slug")));
}
private static DefaultHttpContext Context(string address, string slug)
{
var context = new DefaultHttpContext();
context.Connection.RemoteIpAddress = IPAddress.Parse(address);
context.Request.RouteValues["slug"] = slug;
return context;
}
}
+1 -1
View File
@@ -454,7 +454,7 @@ builder.Services.AddRateLimiter(options =>
options.AddPolicy("public-pdf", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"public-pdf:{context.Request.RouteValues["slug"]?.ToString() ?? "unknown"}",
partitionKey: RateLimitPartitionKeys.PublicPdf(context),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 3,
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Http;
namespace JobTrackerApi.Services;
public static class RateLimitPartitionKeys
{
public static string PublicPdf(HttpContext context)
{
var client = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var slug = context.Request.RouteValues["slug"]?.ToString() ?? "unknown";
return $"public-pdf:{client}:{slug}";
}
}
@@ -1 +0,0 @@
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJKb2JUcmFja2VyQXBpIiwiYXVkIjoiam9iLXRyYWNrZXItdWkiLCJuYmYiOjE3NzQ2MDE0ODEsImV4cCI6MTc3NDY0NDY4NiwiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiOiIyM2RjMTk2Yi1mMjI3LTQ0OTktOTNmZS00MDNkODgwMWUyMWMiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9lbWFpbGFkZHJlc3MiOiJhZG1pbkBleGFtcGxlLmNvbSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiJhZG1pbkBleGFtcGxlLmNvbSIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IkFkbWluIn0.xraA-RMiGjTYEwH27uwgoDrqkgTkVa86Q54A9b15Vts
+2
View File
@@ -33,6 +33,8 @@ operator/external dependencies belong in `BLOCKERS.md`.
the full npm high-severity audit to a blocking CI gate.
- Corrected Chromium PDF export argument handling, bounded hung exports, and verified the returned
public artifact is a real PDF in the browser smoke suite.
- Sandboxed authenticated CV preview iframes without enabling scripts, isolated public-PDF request
budgets by client and slug, and removed/ignored the tracked expired acceptance JWT artifact.
- Removed unfinished Portfolio/Notes workspace navigation promises; existing project, attachment,
and application-note surfaces remain authoritative, and stale section links fall back to Overview.
+2 -2
View File
@@ -152,8 +152,8 @@ No secret value is reproduced here.
| Secret type | Location | Exposure risk | Remediation |
|---|---|---|---|
| Expired local JWT artifact | `docs/_archive/artifacts/s06-acceptance/.dev-auth-token.txt:1` | Tracked/historical credential pattern; token is expired and lacks current required session ID | Remove from reachable source history according to repository policy; use redacted fixtures |
| ASP.NET Data Protection keys | Historical `JobTrackerApi/keys/...` and `keys/...` paths | Historical key material may decrypt data protected under the matching ring | Rotation is documented as completed but not independently verified; preserve incident evidence and rotate affected protected data/tokens as needed |
| Expired local JWT artifact | Historical `docs/_archive/artifacts/s06-acceptance/.dev-auth-token.txt` | Removed from the tracked tree on 2026-08-15 and explicitly ignored; older Git history still contains the expired pattern | Keep generated acceptance tokens untracked. Any history rewrite requires separate repository-owner coordination. |
| ASP.NET Data Protection keys | Historical `JobTrackerApi/keys/...` and `keys/...` paths | Historical key material may decrypt data protected under the matching ring | The operator reports the production ring was rotated. Preserve incident evidence; independent production verification remains part of release operations. |
| Production/runtime secrets | Compose `.env` inputs and CI secret store | Values not present in audit output; mutable CI/deploy dependencies can access them | Pin provenance and least-privilege runners/actions; keep values out of repository/logs |
## AI-specific abuse cases
+1
View File
@@ -218,3 +218,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-184 | Shared synchronous AI provider decorator, durable/workspace suppression scopes, quota exception handler and full backend | Repository root | Make numeric Free/Pro AI limits universal without double-counting already-reserved work | PASS — focused shared-provider/accounting suite 25/25 and backend 674/674; success finalizes measured characters, Free/exhausted requests stop before provider I/O, workspace/operation scopes create no second row, and quota failures return stable 429 details | Fake in-process provider and SQLite only; no model, Stripe, MariaDB or production call | POL-001 repository accounting gap closed; Stripe lifecycle and production smoke remain |
| V-185 | Stripe gateway seam, mocked checkout/webhook lifecycle, entitlement tests and full backend | Repository root | Prove checkout identity and downgrade safety without using external Stripe | PASS — entitlement/billing 33/33 and backend 677/677; configured `price_` and stable user metadata reach Checkout, active grants Pro, `past_due` revokes it, canceled replay remains revoked without duplicate role mutation, non-AI profile data survives, and `prod_` in the price setting fails closed | In-process fake only; no Stripe network, customer, secret mutation, MariaDB or production call | Local POL-001 Stripe lifecycle gap closed; configured Stripe account journey remains blocked |
| V-186 | Migration-chain regression tests, EF model parity/scripts, direct EF SQLite, real application startup and disposable MariaDB 11.8 fresh/restart | Repository root / disposable local databases | Close the historical blank-chain defect without changing applied production state or losing populated rows | PASS — migration tests 3/3 and backend 680/680; blank SQLite reaches all 29 migrations twice, an older populated checkpoint preserves job title/date/owner/summary, EF-only SQLite subsequently serves `/health`, and fresh/restarted MariaDB serves `/health` with 29 migrations, 49 tables and provider-correct sampled ID/owner/decimal/timestamp types. MariaDB script constrains identifiers to 64 characters | Synthetic disposable databases only; no production migration, downgrade, backup restore or private row. Migration/reconciler dual ownership remains JT-019 architectural debt | Blank-chain blocker closed; production restore/rollout remains gated |
| V-187 | Focused public-CV/rate-key tests, focused authenticated-preview Jest, full backend, production frontend build and tracked-tree secret-pattern scan | Repository root / `job-tracker-ui` | Close the remaining low-risk public PDF, preview sandbox and tracked JWT hardening findings | PASS — backend focused 4/4 and full 681/681; preview Jest 15/15; production build passes. Two clients receive independent same-slug PDF partitions, both authenticated preview iframes disable scripts with a sandbox, and the values-suppressed tracked-tree scan finds no JWT/private-key pattern outside audit/operations records | No stress test, production request, history rewrite or Data Protection inspection. A coordinated history rewrite remains outside this change | JT-023/JT-025 current-tree gaps closed; expired JT-020 artifact removed from current tree |
+1
View File
@@ -68,6 +68,7 @@ This queue records the highest-value work that can proceed without production cr
| 12 | Prove email-token and Stripe downgrade lifecycles | SEC-005B, POL-001 | Locally complete with real Identity-token replay/expiry/custom-username tests and fake-gateway active/past-due/canceled Stripe transitions. External SMTP/Stripe journeys remain blocked. |
| 13 | Remove the Job email 100-application selector ceiling | MAIL-001 | Locally complete through bounded owner-filtered server search and tenant/UI regressions. |
| 14 | Repair the full historical migration chain | CORE-001, JT-019 | Locally complete for blank/idempotent/populated SQLite, EF-only-to-application startup, provider scripts and disposable MariaDB 11.8 fresh/restart. Production restore/rollout remains blocked. |
| 15 | Close low-risk CV/public-edge hardening | JT-020, JT-023, JT-025 | Locally complete. Authenticated previews are sandboxed without scripts, public PDF budgets are client-and-slug scoped, and the expired tracked JWT fixture is removed and ignored. Git history remediation remains a separate coordinated decision. |
## Requirement coverage index
@@ -1101,7 +1101,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Typography variant="overline">PDF-style preview</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here.</Typography>
{tailoredCvPreview ? (
<iframe title="Tailored CV preview" srcDoc={tailoredCvPreview.html} style={{ width: "100%", minHeight: 780, border: "1px solid rgba(15,23,42,0.08)", borderRadius: 12, background: "white" }} />
<iframe title="Tailored CV preview" srcDoc={tailoredCvPreview.html} sandbox="allow-same-origin" style={{ width: "100%", minHeight: 780, border: "1px solid rgba(15,23,42,0.08)", borderRadius: 12, background: "white" }} />
) : (
<Typography sx={{ color: "text.secondary" }}>Build the PDF layout preview to inspect the ATS template before downloading.</Typography>
)}
@@ -224,5 +224,6 @@ test('preview failure is visible and retryable without leaving the editor', asyn
expect(await screen.findByText('Preview unavailable')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry preview' }));
await waitFor(() => expect(screen.getByTitle('CV preview')).toHaveAttribute('srcdoc', '<p>retry</p>'));
expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin');
await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument());
});
@@ -229,7 +229,7 @@ test('template switching refreshes preview and export uses the selected template
}));
});
expect(await screen.findByTitle('Tailored CV preview')).toBeInTheDocument();
expect(await screen.findByTitle('Tailored CV preview')).toHaveAttribute('sandbox', 'allow-same-origin');
const appendChildSpy = jest.spyOn(document.body, 'appendChild');
const removeSpy = jest.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => {});
@@ -408,6 +408,7 @@ export default function CvBuilderEditor() {
ref={iframeRef}
title="CV preview"
srcDoc={html}
sandbox="allow-same-origin"
onLoad={onIframeLoad}
style={{
width: `${pageMetrics.widthMm}mm`, height: `${previewHeight}px`, border: "none",