From 090862d3a70861e7af0b72041cdba02ab8c7e5a3 Mon Sep 17 00:00:00 2001 From: juprodh Date: Wed, 17 Jun 2026 17:05:01 +0800 Subject: [PATCH] Add wiki language sceurities --- src/firehose/handlers.ts | 7 +++++-- src/lib/i18n/en.ts | 2 ++ src/lib/i18n/fr.ts | 2 ++ src/lib/i18n/index.ts | 1 + src/lib/languages.ts | 12 ++++++++++++ src/lib/orchestrators/wiki.ts | 5 +++++ src/views/note.ts | 3 ++- tests/firehose/handlers.test.ts | 19 +++++++++++++++++++ tests/lib/languages.test.ts | 30 ++++++++++++++++++++++++++++++ tests/views/note.test.ts | 22 ++++++++++++++++++++++ 10 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/lib/languages.test.ts create mode 100644 tests/views/note.test.ts diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index f613a12..124bcb5 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -1,5 +1,6 @@ import { canEdit, getAccessLevel } from "../lib/access.ts"; import { COLLECTIONS, normalizeRole } from "../lib/collections.ts"; +import { isValidLanguageTag } from "../lib/languages.ts"; import { LIMITS } from "../lib/limits.ts"; import { applyRevisionFromFirehose, @@ -85,8 +86,10 @@ function wikiExceedsLimits(atUri: string, r: WikiRecord): boolean { logDrop(atUri, "wiki.visibility exceeds limit"); return true; } - if (r.language !== undefined && r.language.length > LIMITS.wiki.language) { - logDrop(atUri, "wiki.language exceeds limit"); + if (r.language !== undefined && !isValidLanguageTag(r.language)) { + // Covers both over-length and malformed/HTML-bearing tags. A valid tag is + // already capped at LIMITS.wiki.language inside isValidLanguageTag. + logDrop(atUri, "wiki.language is not a valid BCP-47 tag"); return true; } if ( diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 81e57b3..4269a2f 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -205,6 +205,8 @@ export const en: Messages = { wikiNameRequired: "Wiki name is required.", wikiSlugExists: 'A wiki with slug "{slug}" already exists.', wikiLanguageRequired: "Language is required.", + wikiLanguageInvalid: + "Language must be a valid BCP-47 tag (e.g. en, pt-BR).", wikiNameTooLong: "Wiki name is too long (max {max} characters).", titleTooLong: "Title is too long (max {max} characters).", contentTooLong: "Content is too long (max {max} characters).", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index a74fdd7..d01d6cb 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -208,6 +208,8 @@ export const fr: PartialMessages = { wikiNameRequired: "Le nom du wiki est requis.", wikiSlugExists: 'Un wiki avec le slug "{slug}" existe déjà.', wikiLanguageRequired: "La langue est requise.", + wikiLanguageInvalid: + "La langue doit être une étiquette BCP-47 valide (p. ex. en, pt-BR).", wikiNameTooLong: "Le nom du wiki est trop long (max {max} caractères).", titleTooLong: "Le titre est trop long (max {max} caractères).", contentTooLong: "Le contenu est trop long (max {max} caractères).", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 5b4126f..e141977 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -199,6 +199,7 @@ export interface Messages { wikiNameRequired: string; wikiSlugExists: string; wikiLanguageRequired: string; + wikiLanguageInvalid: string; wikiNameTooLong: string; titleTooLong: string; contentTooLong: string; diff --git a/src/lib/languages.ts b/src/lib/languages.ts index 760452e..1e07033 100644 --- a/src/lib/languages.ts +++ b/src/lib/languages.ts @@ -1,3 +1,5 @@ +import { LIMITS } from "./limits.ts"; + export const WIKI_LANGUAGES = [ { code: "en", label: "English" }, { code: "fr", label: "Francais" }, @@ -10,3 +12,13 @@ export const WIKI_LANGUAGES = [ { code: "ar", label: "Arabic" }, { code: "eo", label: "Esperanto" }, ] as const; + +// Wikis carry a free-form BCP-47 content-language tag (not limited to the +// dropdown above), so validate by shape rather than a fixed list. This is the +// write-side half of the defense; views must still escape the value, since +// records from third-party PDSes bypass this check entirely. +const BCP47_TAG = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/; + +export function isValidLanguageTag(tag: string): boolean { + return tag.length <= LIMITS.wiki.language && BCP47_TAG.test(tag); +} diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index 9585506..ea4ccb3 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -28,6 +28,7 @@ import { ValidationError, } from "../errors.ts"; import { fmt, type Messages, t } from "../i18n/index.ts"; +import { isValidLanguageTag } from "../languages.ts"; import { LIMITS } from "../limits.ts"; import { isValidSlug, slugify } from "../slug.ts"; import { withPdsError } from "./helpers.ts"; @@ -77,6 +78,10 @@ export async function createWikiCore( throw new ValidationError(msg.error.wikiLanguageRequired); } + if (!isValidLanguageTag(fields.language)) { + throw new ValidationError(msg.error.wikiLanguageInvalid); + } + const slug = slugify(fields.name); if (!isValidSlug(slug)) { diff --git a/src/views/note.ts b/src/views/note.ts index befa9c2..56e9287 100644 --- a/src/views/note.ts +++ b/src/views/note.ts @@ -1,3 +1,4 @@ +import { escapeHtml } from "../lib/html.ts"; import type { WikiIdentity } from "../lib/wiki-identity.ts"; import { type LayoutOptions, layout } from "./layout.ts"; @@ -8,7 +9,7 @@ export function notePage( options?: LayoutOptions, wikiLanguage?: string, ): string { - const langAttr = wikiLanguage ? ` lang="${wikiLanguage}"` : ""; + const langAttr = wikiLanguage ? ` lang="${escapeHtml(wikiLanguage)}"` : ""; const content = `
${renderedHtml} diff --git a/tests/firehose/handlers.test.ts b/tests/firehose/handlers.test.ts index d2a754a..982d10a 100644 --- a/tests/firehose/handlers.test.ts +++ b/tests/firehose/handlers.test.ts @@ -43,6 +43,7 @@ const HANDLER_TEST_WIKIS = [ "incomplete-wiki", "lang-wiki", "nolang-wiki", + "evil-lang-wiki", "oversize-wiki", "theme-enforced", "theme-unknown", @@ -782,6 +783,24 @@ describe("size guards", () => { expect(getWiki(ALICE_DID, "oversize-wiki")).toBeNull(); }); + test("drops wiki record with a non-BCP-47 (HTML-bearing) language", () => { + handleCommitEvent( + makeCommitEvt({ + event: "create", + collection: "wiki.lichen.wiki", + rkey: "evil-lang-wiki", + did: ALICE_DID, + record: { + name: "Evil Lang", + visibility: "public", + language: 'en" onmouseover="alert(1)', + createdAt: "2026-01-01T00:00:00.000Z", + }, + }), + ); + expect(getWiki(ALICE_DID, "evil-lang-wiki")).toBeNull(); + }); + test("drops revision record with oversize diff", () => { handleCommitEvent( makeCommitEvt({ diff --git a/tests/lib/languages.test.ts b/tests/lib/languages.test.ts new file mode 100644 index 0000000..e489da3 --- /dev/null +++ b/tests/lib/languages.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { isValidLanguageTag } from "../../src/lib/languages.ts"; + +describe("isValidLanguageTag", () => { + test("accepts well-formed BCP-47 tags", () => { + for (const tag of ["en", "fr", "pt-BR", "zh-Hant", "de-DE", "eo"]) { + expect(isValidLanguageTag(tag)).toBe(true); + } + }); + + test("rejects HTML-bearing or malformed tags", () => { + for (const tag of [ + 'en" onmouseover=alert(1)', + "en>