diff --git a/bun.lockb b/bun.lockb index 901f0977..1e191d27 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database/entities/User.ts b/database/entities/User.ts index ed245970..26dc308b 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -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: { diff --git a/package.json b/package.json index 7ef5c4cd..02f0acdc 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index ffcdc0d2..14339ab5 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -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 => { "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 => { // 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 => { } // 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 => { } await user.save(); + await user.updateActor(); return jsonResponse(await user.toAPI()); }; diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index f8fe63c9..ba2e7a53 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -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 => { 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 => { const newStatus = await Status.createNew({ account: user, application, - content: status, + content: sanitizedStatus, visibility: visibility || (config.defaults.visibility as diff --git a/utils/config.ts b/utils/config.ts index 8ab1d030..fb46f025 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -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: { diff --git a/utils/sanitization.ts b/utils/sanitization.ts new file mode 100644 index 00000000..7512a063 --- /dev/null +++ b/utils/sanitization.ts @@ -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(); +};