diff --git a/bun.lockb b/bun.lockb index fd3be1f6..2d93ce0f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 4e313c25..9e88a72f 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -555,14 +555,14 @@ export const contentToHtml = async ( let htmlContent: string; if (content["text/html"]) { - htmlContent = content["text/html"].content; + htmlContent = await sanitizeHtml(content["text/html"].content); } else if (content["text/markdown"]) { htmlContent = await sanitizeHtml( await markdownParse(content["text/markdown"].content), ); } else if (content["text/plain"]?.content) { // Split by newline and add
tags - htmlContent = content["text/plain"].content + htmlContent = (await sanitizeHtml(content["text/plain"].content)) .split("\n") .map((line) => `
${line}
`) .join("\n"); diff --git a/package.json b/package.json index 68f27d9b..a7e1a481 100644 --- a/package.json +++ b/package.json @@ -77,10 +77,8 @@ "cli-parser": "workspace:*", "cli-table": "^0.3.11", "config-manager": "workspace:*", - "dompurify": "^3.1.2", "drizzle-orm": "^0.30.7", "extract-zip": "^2.0.1", - "happy-dom": "14.7.1", "html-to-text": "^9.0.5", "ioredis": "^5.3.2", "ip-matching": "^2.1.2", @@ -103,6 +101,8 @@ "request-parser": "workspace:*", "sharp": "^0.33.3", "string-comparison": "^1.3.0", + "stringify-entities": "^4.0.4", + "xss": "^1.0.15", "zod": "^3.22.4", "zod-validation-error": "^3.2.0" } diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index 13f060f8..adcabfaa 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -45,6 +45,7 @@ import { config } from "~packages/config-manager"; import type { Attachment as APIAttachment } from "~types/mastodon/attachment"; import type { Status as APIStatus } from "~types/mastodon/status"; import { User } from "./user"; +import { sanitizedHtmlStrip } from "@sanitization"; /** * Gives helpers to fetch notes from database in a nice format @@ -208,7 +209,7 @@ export class Note { contentType: "text/html", visibility, sensitive: is_sensitive, - spoilerText: spoiler_text, + spoilerText: await sanitizedHtmlStrip(spoiler_text), uri: uri || null, replyId: replyId ?? null, quotingId: quoteId ?? null, diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index e0065908..cdb000ed 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -1,6 +1,6 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { sanitizeHtml } from "@sanitization"; +import { sanitizeHtml, sanitizedHtmlStrip } from "@sanitization"; import { config } from "config-manager"; import { and, eq } from "drizzle-orm"; import ISO6391 from "iso-639-1"; @@ -97,10 +97,9 @@ export default apiRouteHi! <script>alert('Hello, world!');</script>
", + ); + }); + + test("should not allow HTML injection in spoiler_text", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + spoiler_text: + "uwu ", + federate: false, + }), + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json", + ); + + const object = (await response.json()) as APIStatus; + + expect(object.spoiler_text).toBe( + "uwu <script>alert('Hello, world!');</script>", + ); + }); + }); }); diff --git a/utils/sanitization.ts b/utils/sanitization.ts index 46e0db28..9e8486b3 100644 --- a/utils/sanitization.ts +++ b/utils/sanitization.ts @@ -1,52 +1,41 @@ -import { config } from "config-manager"; -import DOMPurify from "dompurify"; -import { GlobalWindow } from "happy-dom"; +import xss, { type IFilterXSSOptions } from "xss"; +import { stringifyEntitiesLight } from "stringify-entities"; -const window = new GlobalWindow(); +export const sanitizedHtmlStrip = (html: string) => { + return sanitizeHtml(html, { + whiteList: {}, + }); +}; export const sanitizeHtml = async ( html: string, - extraConfig?: DOMPurify.Config, + extraConfig?: IFilterXSSOptions, ) => { - // @ts-expect-error Types clash but it works i swear - const sanitizedHtml = DOMPurify(window).sanitize(html, { - ALLOWED_TAGS: [ - "a", - "p", - "br", - "b", - "i", - "em", - "strong", - "del", - "code", - "u", - "pre", - "ul", - "ol", - "li", - "blockquote", - ], - ALLOWED_ATTR: [ - "href", - "target", - "title", - "rel", - "class", - "start", - "reversed", - "value", - ], - ALLOWED_URI_REGEXP: new RegExp( - `/^(?:(?:${config.validation.url_scheme_whitelist.join( - "|", - )}):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i`, - ), - USE_PROFILES: { - mathMl: true, + const sanitizedHtml = xss(html, { + whiteList: { + a: ["href", "title", "target", "rel", "class"], + p: ["class"], + br: ["class"], + b: ["class"], + i: ["class"], + em: ["class"], + strong: ["class"], + del: ["class"], + code: ["class"], + u: ["class"], + pre: ["class"], + ul: ["class"], + ol: ["class"], + li: ["class"], + blockquote: ["class"], }, + stripIgnoreTag: false, + escapeHtml: (unsafeHtml) => + stringifyEntitiesLight(unsafeHtml, { + escapeOnly: true, + }), ...extraConfig, - }) as string; + }); // Check text to only allow h-*, p-*, u-*, dt-*, e-*, mention, hashtag, ellipsis, invisible classes const allowedClasses = [