fix(security): harden public CV edges
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user