From 8ef8b098c8c96c03748e7edd0f0388e654fdf32f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 15 Aug 2026 20:53:16 +0200 Subject: [PATCH] fix(security): harden public CV edges --- .gitignore | 1 + .../RateLimitPartitionKeysTests.cs | 29 +++++++++++++++++++ JobTrackerApi/Program.cs | 2 +- .../Services/RateLimitPartitionKeys.cs | 13 +++++++++ .../s06-acceptance/.dev-auth-token.txt | 1 - docs/architecture/technical-debt.md | 2 ++ docs/audits/security-threat-model.md | 4 +-- docs/audits/verification-log.md | 1 + docs/work-programmes/master-work-plan.md | 1 + .../src/components/JobDetailsDialog.tsx | 2 +- .../src/cv-builder-deep-link.test.tsx | 1 + .../src/job-details-generated-drafts.test.tsx | 2 +- job-tracker-ui/src/views/CvBuilderEditor.tsx | 1 + 13 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 JobTrackerApi.Tests/RateLimitPartitionKeysTests.cs create mode 100644 JobTrackerApi/Services/RateLimitPartitionKeys.cs delete mode 100644 docs/_archive/artifacts/s06-acceptance/.dev-auth-token.txt 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, PDF-style preview Preview and PDF export use the same HTML template contract. Accent color and photo settings apply here. {tailoredCvPreview ? ( -