diff --git a/.gitignore b/.gitignore
index 51ec9ba..734f5ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/JobTrackerApi.Tests/RateLimitPartitionKeysTests.cs b/JobTrackerApi.Tests/RateLimitPartitionKeysTests.cs
new file mode 100644
index 0000000..3733853
--- /dev/null
+++ b/JobTrackerApi.Tests/RateLimitPartitionKeysTests.cs
@@ -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;
+ }
+}
diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs
index 0375819..ef904d4 100644
--- a/JobTrackerApi/Program.cs
+++ b/JobTrackerApi/Program.cs
@@ -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,
diff --git a/JobTrackerApi/Services/RateLimitPartitionKeys.cs b/JobTrackerApi/Services/RateLimitPartitionKeys.cs
new file mode 100644
index 0000000..4974984
--- /dev/null
+++ b/JobTrackerApi/Services/RateLimitPartitionKeys.cs
@@ -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}";
+ }
+}
diff --git a/docs/_archive/artifacts/s06-acceptance/.dev-auth-token.txt b/docs/_archive/artifacts/s06-acceptance/.dev-auth-token.txt
deleted file mode 100644
index f04ce05..0000000
--- a/docs/_archive/artifacts/s06-acceptance/.dev-auth-token.txt
+++ /dev/null
@@ -1 +0,0 @@
-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJKb2JUcmFja2VyQXBpIiwiYXVkIjoiam9iLXRyYWNrZXItdWkiLCJuYmYiOjE3NzQ2MDE0ODEsImV4cCI6MTc3NDY0NDY4NiwiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvbmFtZWlkZW50aWZpZXIiOiIyM2RjMTk2Yi1mMjI3LTQ0OTktOTNmZS00MDNkODgwMWUyMWMiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9lbWFpbGFkZHJlc3MiOiJhZG1pbkBleGFtcGxlLmNvbSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiOiJhZG1pbkBleGFtcGxlLmNvbSIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IkFkbWluIn0.xraA-RMiGjTYEwH27uwgoDrqkgTkVa86Q54A9b15Vts
\ No newline at end of file
diff --git a/docs/architecture/technical-debt.md b/docs/architecture/technical-debt.md
index 30a82e8..e7b28b1 100644
--- a/docs/architecture/technical-debt.md
+++ b/docs/architecture/technical-debt.md
@@ -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.
diff --git a/docs/audits/security-threat-model.md b/docs/audits/security-threat-model.md
index 0e4a916..e72b20c 100644
--- a/docs/audits/security-threat-model.md
+++ b/docs/audits/security-threat-model.md
@@ -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
diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md
index 742e524..5974087 100644
--- a/docs/audits/verification-log.md
+++ b/docs/audits/verification-log.md
@@ -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 |
diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md
index 2649ce6..1113bbd 100644
--- a/docs/work-programmes/master-work-plan.md
+++ b/docs/work-programmes/master-work-plan.md
@@ -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
diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx
index c4b13f4..3af1c4e 100644
--- a/job-tracker-ui/src/components/JobDetailsDialog.tsx
+++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx
@@ -1101,7 +1101,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
retry
')); + expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin'); await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument()); }); diff --git a/job-tracker-ui/src/job-details-generated-drafts.test.tsx b/job-tracker-ui/src/job-details-generated-drafts.test.tsx index 7183e3f..b039238 100644 --- a/job-tracker-ui/src/job-details-generated-drafts.test.tsx +++ b/job-tracker-ui/src/job-details-generated-drafts.test.tsx @@ -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(() => {}); diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx index 0997257..4b4c939 100644 --- a/job-tracker-ui/src/views/CvBuilderEditor.tsx +++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx @@ -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",