Add sanitization to HTML

This commit is contained in:
Jesse Wierzbinski 2023-10-16 12:03:29 -10:00
parent 3c289dd3de
commit f677737fdd
7 changed files with 110 additions and 13 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -263,8 +263,8 @@ export class User extends BaseEntity {
await user.generateKeys();
await user.updateActor();
await user.save();
return user;
}
@ -403,6 +403,7 @@ export class User extends BaseEntity {
outbox: `${config.http.base_url}/users/${this.username}/outbox`,
followers: `${config.http.base_url}/users/${this.username}/followers`,
following: `${config.http.base_url}/users/${this.username}/following`,
published: new Date(this.created_at).toISOString(),
manuallyApprovesFollowers: false,
summary: this.note,
icon: {

View file

@ -57,7 +57,9 @@
},
"dependencies": {
"ip-matching": "^2.1.2",
"isomorphic-dompurify": "^1.9.0",
"jsonld": "^8.3.1",
"marked": "^9.1.2",
"pg": "^8.11.3",
"reflect-metadata": "^0.1.13",
"typeorm": "^0.3.17"

View file

@ -3,6 +3,8 @@ import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { User } from "~database/entities/User";
import { applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify";
import { sanitizeHtml } from "@sanitization";
export const meta = applyConfig({
allowedMethods: ["PATCH"],
@ -50,11 +52,18 @@ export default async (req: Request): Promise<Response> => {
"source[language]": string;
}>(req);
const sanitizedNote = await sanitizeHtml(note ?? "");
const sanitizedDisplayName = sanitize(display_name, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
});
if (display_name) {
// Check if within allowed display name lengths
if (
display_name.length < 3 ||
display_name.length > config.validation.max_displayname_size
sanitizedDisplayName.length < 3 ||
sanitizedDisplayName.length > config.validation.max_displayname_size
) {
return errorResponse(
`Display name must be between 3 and ${config.validation.max_displayname_size} characters`,
@ -65,19 +74,19 @@ export default async (req: Request): Promise<Response> => {
// Check if display name doesnt match filters
if (
config.filters.displayname_filters.some(filter =>
display_name.match(filter)
sanitizedDisplayName.match(filter)
)
) {
return errorResponse("Display name contains blocked words", 422);
}
user.actor.data.name = display_name;
user.display_name = display_name;
user.actor.data.name = sanitizedDisplayName;
user.display_name = sanitizedDisplayName;
}
if (note) {
// Check if within allowed note length
if (note.length > config.validation.max_note_size) {
if (sanitizedNote.length > config.validation.max_note_size) {
return errorResponse(
`Note must be less than ${config.validation.max_note_size} characters`,
422
@ -85,12 +94,16 @@ export default async (req: Request): Promise<Response> => {
}
// Check if bio doesnt match filters
if (config.filters.bio_filters.some(filter => note.match(filter))) {
if (
config.filters.bio_filters.some(filter =>
sanitizedNote.match(filter)
)
) {
return errorResponse("Bio contains blocked words", 422);
}
user.actor.data.summary = note;
user.note = note;
user.actor.data.summary = sanitizedNote;
user.note = sanitizedNote;
}
if (source_privacy) {
@ -177,6 +190,7 @@ export default async (req: Request): Promise<Response> => {
}
await user.save();
await user.updateActor();
return jsonResponse(await user.toAPI());
};

View file

@ -5,7 +5,9 @@ import { applyConfig } from "@api";
import { getConfig } from "@config";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization";
import { APActor } from "activitypub-types";
import { sanitize } from "isomorphic-dompurify";
import { Application } from "~database/entities/Application";
import { RawObject } from "~database/entities/RawObject";
import { Status } from "~database/entities/Status";
@ -72,7 +74,9 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Status is required", 422);
}
if (status.length > config.validation.max_note_size) {
const sanitizedStatus = await sanitizeHtml(status);
if (sanitizedStatus.length > config.validation.max_note_size) {
return errorResponse(
`Status must be less than ${config.validation.max_note_size} characters`,
400
@ -134,7 +138,7 @@ export default async (req: Request): Promise<Response> => {
const newStatus = await Status.createNew({
account: user,
application,
content: status,
content: sanitizedStatus,
visibility:
visibility ||
(config.defaults.visibility as

View file

@ -91,7 +91,7 @@ export const configDefaults: ConfigType = {
http: {
bind: "http://0.0.0.0",
bind_port: "8000",
base_url: "http://fediproject.localhost:8000",
base_url: "http://lysand.localhost:8000",
banned_ips: [],
},
database: {

76
utils/sanitization.ts Normal file
View file

@ -0,0 +1,76 @@
import { getConfig } from "@config";
import { sanitize } from "isomorphic-dompurify";
export const sanitizeHtml = async (html: string) => {
const config = getConfig();
const sanitizedHtml = 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,
},
});
// Check text to only allow h-*, p-*, u-*, dt-*, e-*, mention, hashtag, ellipsis, invisible classes
const allowedClasses = [
"h-",
"p-",
"u-",
"dt-",
"e-",
"mention",
"hashtag",
"ellipsis",
"invisible",
];
return await new HTMLRewriter()
.on("*[class]", {
element(element) {
const classes = element.getAttribute("class")?.split(" ") ?? [];
classes.forEach(className => {
if (
!allowedClasses.some(allowedClass =>
className.startsWith(allowedClass)
)
) {
element.removeAttribute("class");
}
});
},
})
.transform(new Response(sanitizedHtml))
.text();
};