diff --git a/backend/src/activitypub/actors/index.ts b/backend/src/activitypub/actors/index.ts index e88e11d..671f6f3 100644 --- a/backend/src/activitypub/actors/index.ts +++ b/backend/src/activitypub/actors/index.ts @@ -43,32 +43,41 @@ export async function get(url: string | URL): Promise { const data = await res.json() const actor: Actor = { ...data } - actor.id = new URL(data.id) + actor.id = new URL(actor.id) - if (data.content) { - actor.content = await sanitizeContent(data.content) + if (actor.summary) { + actor.summary = await sanitizeContent(actor.summary) + if (actor.summary.length > 500) { + actor.summary = actor.summary.substring(0, 500) + } } - if (data.name) { - actor.name = await getTextContent(data.name) + if (actor.name) { + actor.name = await getTextContent(actor.name) + if (actor.name.length > 30) { + actor.name = actor.name.substring(0, 30) + } } - if (data.preferredUsername) { - actor.preferredUsername = await getTextContent(data.preferredUsername) + if (actor.preferredUsername) { + actor.preferredUsername = await getTextContent(actor.preferredUsername) + if (actor.preferredUsername.length > 30) { + actor.preferredUsername = actor.preferredUsername.substring(0, 30) + } } // This is mostly for testing where for convenience not all values // are provided. // TODO: eventually clean that to better match production. - if (data.inbox !== undefined) { - actor.inbox = new URL(data.inbox) + if (actor.inbox !== undefined) { + actor.inbox = new URL(actor.inbox) } - if (data.following !== undefined) { - actor.following = new URL(data.following) + if (actor.following !== undefined) { + actor.following = new URL(actor.following) } - if (data.followers !== undefined) { - actor.followers = new URL(data.followers) + if (actor.followers !== undefined) { + actor.followers = new URL(actor.followers) } - if (data.outbox !== undefined) { - actor.outbox = new URL(data.outbox) + if (actor.outbox !== undefined) { + actor.outbox = new URL(actor.outbox) } return actor diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts index c1f552b..63b22cf 100644 --- a/backend/src/errors/index.ts +++ b/backend/src/errors/index.ts @@ -7,7 +7,7 @@ type ErrorResponse = { const headers = { ...cors(), - 'content-type': 'application/json', + 'content-type': 'application/json; charset=utf-8', } as const function generateErrorResponse(error: string, status: number, errorDescription?: string): Response { @@ -57,3 +57,7 @@ export function exceededLimit(detail: string): Response { export function resourceNotFound(name: string, id: string): Response { return generateErrorResponse('Resource not found', 404, `${name} "${id}" not found`) } + +export function validationError(detail: string): Response { + return generateErrorResponse('Validation failed', 422, detail) +} diff --git a/backend/test/activitypub.spec.ts b/backend/test/activitypub.spec.ts index 6a46173..9162a86 100644 --- a/backend/test/activitypub.spec.ts +++ b/backend/test/activitypub.spec.ts @@ -22,43 +22,89 @@ const vapidKeys = {} as JWK const domain = 'cloudflare.com' describe('ActivityPub', () => { - test('fetch non-existant user by id', async () => { - const db = await makeDB() + describe('Actors', () => { + test('fetch non-existant user by id', async () => { + const db = await makeDB() - const res = await ap_users.handleRequest(domain, db, 'nonexisting') - assert.equal(res.status, 404) - }) + const res = await ap_users.handleRequest(domain, db, 'nonexisting') + assert.equal(res.status, 404) + }) - test('fetch user by id', async () => { - const db = await makeDB() - const properties = { - summary: 'test summary', - inbox: 'https://example.com/inbox', - outbox: 'https://example.com/outbox', - following: 'https://example.com/following', - followers: 'https://example.com/followers', - } - const pubKey = - '-----BEGIN PUBLIC KEY-----MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApnI8FHJQXqqAdM87YwVseRUqbNLiw8nQ0zHBUyLylzaORhI4LfW4ozguiw8cWYgMbCufXMoITVmdyeTMGbQ3Q1sfQEcEjOZZXEeCCocmnYjK6MFSspjFyNw6GP0a5A/tt1tAcSlgALv8sg1RqMhSE5Kv+6lSblAYXcIzff7T2jh9EASnimaoAAJMaRH37+HqSNrouCxEArcOFhmFETadXsv+bHZMozEFmwYSTugadr4WD3tZd+ONNeimX7XZ3+QinMzFGOW19ioVHyjt3yCDU1cPvZIDR17dyEjByNvx/4N4Zly7puwBn6Ixy/GkIh5BWtL5VOFDJm/S+zcf1G1WsOAXMwKL4Nc5UWKfTB7Wd6voId7vF7nI1QYcOnoyh0GqXWhTPMQrzie4nVnUrBedxW0s/0vRXeR63vTnh5JrTVu06JGiU2pq2kvwqoui5VU6rtdImITybJ8xRkAQ2jo4FbbkS6t49PORIuivxjS9wPl7vWYazZtDVa5g/5eL7PnxOG3HsdIJWbGEh1CsG83TU9burHIepxXuQ+JqaSiKdCVc8CUiO++acUqKp7lmbYR9E/wRmvxXDFkxCZzA0UL2mRoLLLOe4aHvRSTsqiHC5Wwxyew5bb+eseJz3wovid9ZSt/tfeMAkCDmaCxEK+LGEbJ9Ik8ihis8Esm21N0A54sCAwEAAQ==-----END PUBLIC KEY-----' - await db - .prepare('INSERT INTO actors (id, email, type, properties, pubkey) VALUES (?, ?, ?, ?, ?)') - .bind(`https://${domain}/ap/users/sven`, 'sven@cloudflare.com', 'Person', JSON.stringify(properties), pubKey) - .run() + test('fetch user by id', async () => { + const db = await makeDB() + const properties = { + summary: 'test summary', + inbox: 'https://example.com/inbox', + outbox: 'https://example.com/outbox', + following: 'https://example.com/following', + followers: 'https://example.com/followers', + } + const pubKey = + '-----BEGIN PUBLIC KEY-----MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApnI8FHJQXqqAdM87YwVseRUqbNLiw8nQ0zHBUyLylzaORhI4LfW4ozguiw8cWYgMbCufXMoITVmdyeTMGbQ3Q1sfQEcEjOZZXEeCCocmnYjK6MFSspjFyNw6GP0a5A/tt1tAcSlgALv8sg1RqMhSE5Kv+6lSblAYXcIzff7T2jh9EASnimaoAAJMaRH37+HqSNrouCxEArcOFhmFETadXsv+bHZMozEFmwYSTugadr4WD3tZd+ONNeimX7XZ3+QinMzFGOW19ioVHyjt3yCDU1cPvZIDR17dyEjByNvx/4N4Zly7puwBn6Ixy/GkIh5BWtL5VOFDJm/S+zcf1G1WsOAXMwKL4Nc5UWKfTB7Wd6voId7vF7nI1QYcOnoyh0GqXWhTPMQrzie4nVnUrBedxW0s/0vRXeR63vTnh5JrTVu06JGiU2pq2kvwqoui5VU6rtdImITybJ8xRkAQ2jo4FbbkS6t49PORIuivxjS9wPl7vWYazZtDVa5g/5eL7PnxOG3HsdIJWbGEh1CsG83TU9burHIepxXuQ+JqaSiKdCVc8CUiO++acUqKp7lmbYR9E/wRmvxXDFkxCZzA0UL2mRoLLLOe4aHvRSTsqiHC5Wwxyew5bb+eseJz3wovid9ZSt/tfeMAkCDmaCxEK+LGEbJ9Ik8ihis8Esm21N0A54sCAwEAAQ==-----END PUBLIC KEY-----' + await db + .prepare('INSERT INTO actors (id, email, type, properties, pubkey) VALUES (?, ?, ?, ?, ?)') + .bind(`https://${domain}/ap/users/sven`, 'sven@cloudflare.com', 'Person', JSON.stringify(properties), pubKey) + .run() - const res = await ap_users.handleRequest(domain, db, 'sven') - assert.equal(res.status, 200) + const res = await ap_users.handleRequest(domain, db, 'sven') + assert.equal(res.status, 200) - const data = await res.json() - assert.equal(data.summary, 'test summary') - assert(data.discoverable) - assert(data['@context']) - assert(isUrlValid(data.id)) - assert(isUrlValid(data.url)) - assert(isUrlValid(data.inbox)) - assert(isUrlValid(data.outbox)) - assert(isUrlValid(data.following)) - assert(isUrlValid(data.followers)) - assert.equal(data.publicKey.publicKeyPem, pubKey) + const data = await res.json() + assert.equal(data.summary, 'test summary') + assert(data.discoverable) + assert(data['@context']) + assert(isUrlValid(data.id)) + assert(isUrlValid(data.url)) + assert(isUrlValid(data.inbox)) + assert(isUrlValid(data.outbox)) + assert(isUrlValid(data.following)) + assert(isUrlValid(data.followers)) + assert.equal(data.publicKey.publicKeyPem, pubKey) + }) + + test('sanitize Actor properties', async () => { + globalThis.fetch = async (input: RequestInfo) => { + if (input === 'https://example.com/actor') { + return new Response( + JSON.stringify({ + id: 'https://example.com/actor', + type: 'Person', + summary: "it's me, Mario. ", + name: 'hi
hey', + preferredUsername: 'sven ', + }) + ) + } + throw new Error(`unexpected request to "${input}"`) + } + + const actor = await actors.get('https://example.com/actor') + assert.equal(actor.summary, "it's me, Mario.

alert(1)

") + assert.equal(actor.name, 'hi hey') + assert.equal(actor.preferredUsername, 'sven alert(1)') + }) + + test('Actor properties limits', async () => { + globalThis.fetch = async (input: RequestInfo) => { + if (input === 'https://example.com/actor') { + return new Response( + JSON.stringify({ + id: 'https://example.com/actor', + type: 'Person', + summary: 'a'.repeat(612), + name: 'b'.repeat(50), + preferredUsername: 'c'.repeat(50), + }) + ) + } + throw new Error(`unexpected request to "${input}"`) + } + + const actor = await actors.get('https://example.com/actor') + assert.equal(actor.summary, 'a'.repeat(500)) + assert.equal(actor.name, 'b'.repeat(30)) + assert.equal(actor.preferredUsername, 'c'.repeat(30)) + }) }) describe('Outbox', () => { diff --git a/backend/test/mastodon/statuses.spec.ts b/backend/test/mastodon/statuses.spec.ts index 9025200..41f64a2 100644 --- a/backend/test/mastodon/statuses.spec.ts +++ b/backend/test/mastodon/statuses.spec.ts @@ -1010,5 +1010,25 @@ describe('Mastodon APIs', () => { assert.equal(results![0].object_id, note.id.toString()) assert.equal(results![1].object_id, note.id.toString()) }) + + test('reject statuses exceeding limits', async () => { + const db = await makeDB() + const queue = makeQueue() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const body = { + status: 'a'.repeat(501), + visibility: 'public', + } + const req = new Request('https://example.com', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache) + assert.equal(res.status, 422) + assertJSON(res) + }) }) }) diff --git a/functions/api/v1/statuses.ts b/functions/api/v1/statuses.ts index e1912b4..8a31595 100644 --- a/functions/api/v1/statuses.ts +++ b/functions/api/v1/statuses.ts @@ -74,6 +74,10 @@ export async function handleRequest( return new Response('', { status: 400 }) } + if (body.status.length > 500) { + return errors.validationError('text character limit of 500 exceeded') + } + const mediaAttachments: Array = [] if (body.media_ids && body.media_ids.length > 0) { if (body.media_ids.length > 4) {