From 170184822196a96fd8bb0e2faab79374cd51eb1b Mon Sep 17 00:00:00 2001 From: Arnaud Gissinger Date: Thu, 2 Jul 2026 14:05:52 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=94=92=20fix(security):=20harden=20li?= =?UTF-8?q?nks,=20email-PIN,=20and=20AI=20moderation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three findings from a security audit of the branch: - XSS (stored): user link/source URLs were rendered straight onto with only a trim(). sanitizeLinks() now scheme-validates via new URL() (http/https/mailto only), case-study `sources` are routed through it (they bypassed it entirely), and a safeUrl() render guard backs the three link render sites as defense-in-depth for any pre-existing data. - Email-PIN abuse: /_auth/email-pin/send was unauthenticated with no HTTP rate limit, allowing a mail-flood/spam relay across arbitrary addresses. Added per-IP + per-email limits on send, and per-IP + per-email limits on verify so PIN brute force is bounded at the HTTP layer, not just by the non-atomic per-code attempt counter. - Moderation prompt injection: user content is now fenced in tags with a system caveat telling the model to treat it as untrusted data; stripFenceTokens() prevents forging the boundary; model-suggested new tags are bounded (<=3, <=40 chars, no empty slugs) so injection can't pollute the global taxonomy. Bumped issue.moderate / case-study.moderate prompt versions and updated the golden snapshots. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/pages/case-study/[id].vue | 5 +- app/pages/issue/[issueId]/index.vue | 4 +- app/utils/safeUrl.ts | 15 ++++ server/api/_auth/email-pin/send.post.ts | 19 +++++ server/api/_auth/email-pin/verify.post.ts | 17 ++++ server/utils/case-study-write.ts | 4 +- server/utils/issue-write.ts | 17 +++- .../moderation-steps.test.ts.snap | 12 ++- tests/unit/moderation-steps.test.ts | 63 ++++++++++---- workers/moderation/src/pipelines.ts | 85 +++++++++++++------ .../src/steps/case-study/moderate.yaml | 6 +- .../moderation/src/steps/issue/moderate.yaml | 8 +- 12 files changed, 200 insertions(+), 55 deletions(-) create mode 100644 app/utils/safeUrl.ts diff --git a/app/pages/case-study/[id].vue b/app/pages/case-study/[id].vue index 74e275b..7e218ce 100644 --- a/app/pages/case-study/[id].vue +++ b/app/pages/case-study/[id].vue @@ -1,4 +1,5 @@ + + diff --git a/app/pages/case-study/[id]/history.vue b/app/pages/case-study/[id]/history.vue new file mode 100644 index 0000000..eda1ab9 --- /dev/null +++ b/app/pages/case-study/[id]/history.vue @@ -0,0 +1,53 @@ + + + diff --git a/app/pages/case-study/[id]/index.vue b/app/pages/case-study/[id]/index.vue new file mode 100644 index 0000000..d66ba4c --- /dev/null +++ b/app/pages/case-study/[id]/index.vue @@ -0,0 +1,300 @@ + + + From ca39bf55a21d3cb68c5127def5095c4fd36039f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:40:13 +0000 Subject: [PATCH 4/8] Deduplicate case-study rendering helpers (DRY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the outcome/scale badge label+variant maps — previously copy-pasted across the case-study page, case-study card, tree node, and issue overview — into a single app/utils/case-study.ts. - Extract the shared "Contributors · History" overview footer into a NodeMetaLinks component, used by both the issue and case-study overviews, and the meta-page "← back" link into NodeBackLink. No visual change; purely removes duplication. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012MFHDAHHvfjqmUf7TycaJJ --- app/components/card/CaseStudy.vue | 29 ++---------- app/components/issue/TreeNode.vue | 18 +------- app/components/node/BackLink.vue | 15 +++++++ app/components/node/MetaLinks.vue | 41 +++++++++++++++++ app/pages/case-study/[id].vue | 30 +++---------- app/pages/case-study/[id]/contributors.vue | 8 +--- app/pages/case-study/[id]/history.vue | 8 +--- app/pages/case-study/[id]/index.vue | 24 +--------- app/pages/issue/[issueId]/index.vue | 51 ++-------------------- app/utils/case-study.ts | 37 ++++++++++++++++ 10 files changed, 109 insertions(+), 152 deletions(-) create mode 100644 app/components/node/BackLink.vue create mode 100644 app/components/node/MetaLinks.vue create mode 100644 app/utils/case-study.ts diff --git a/app/components/card/CaseStudy.vue b/app/components/card/CaseStudy.vue index 0e9272d..ba1ceb2 100644 --- a/app/components/card/CaseStudy.vue +++ b/app/components/card/CaseStudy.vue @@ -37,29 +37,6 @@ interface CaseStudy { const props = defineProps<{ study: CaseStudy }>() -const outcomeVariant: Record = { - success: 'success', - partial: 'default', - failed: 'error', - inconclusive: 'default', - ongoing: 'warning', -} -const outcomeLabel: Record = { - success: 'Success', - partial: 'Partial', - failed: 'Failed', - inconclusive: 'Inconclusive', - ongoing: 'Ongoing', -} - -const scaleLabel: Record = { - neighborhood: 'Neighborhood', - city: 'City', - region: 'Region', - national: 'National', - global: 'Global', -} - function yearOf(s?: string | null): string | null { if (!s) return null const m = /^(\d{4})/.exec(s) @@ -130,8 +107,8 @@ const sourceCount = computed(() => props.study.sources?.length ?? 0) title="Verified" /> - - {{ outcomeLabel[study.outcome] }} + + {{ outcomeBadgeLabel(study.outcome) }} @@ -157,7 +134,7 @@ const sourceCount = computed(() => props.study.sources?.length ?? 0) - {{ scaleLabel[study.scale] ?? study.scale }} + {{ scaleBadgeLabel(study.scale) }} { return 'text-gray-400' }) -const outcomeVariant: Record = { - success: 'success', - partial: 'default', - failed: 'error', - inconclusive: 'default', - ongoing: 'warning', -} -const outcomeLabel: Record = { - success: 'Success', - partial: 'Partial', - failed: 'Failed', - inconclusive: 'Inconclusive', - ongoing: 'Ongoing', -}