diff --git a/src/activitypub/actors.js b/src/activitypub/actors.js index a581c9081e..3736a93df7 100644 --- a/src/activitypub/actors.js +++ b/src/activitypub/actors.js @@ -36,7 +36,7 @@ Actors.assert = async (ids, options = {}) => { if (id.includes('@') && !(isUri && activitypub._constants.acceptedProtocols.includes(new URL(id).protocol.slice(0, -1)))) { const host = isUri ? new URL(id).host : id.split('@')[1]; if (host === nconf.get('url_parsed').host) { // do not assert loopback ids - return null; + return 'loopback'; } ({ actorUri: id } = await activitypub.helpers.query(id)); @@ -53,10 +53,7 @@ Actors.assert = async (ids, options = {}) => { } // Filter out loopback uris - ids = ids.filter((uri) => { - const { host } = new URL(uri); - return host !== nconf.get('url_parsed').host; - }); + ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host); // Filter out existing if (!options.update) { diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index 45fc36bfa9..4ab32d5c03 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -136,6 +136,7 @@ Helpers.resolveLocalId = async (input) => { return { type: null, id: null }; } else if (String(input).indexOf('@') !== -1) { // Webfinger + input = decodeURIComponent(input); const [slug] = input.replace(/^acct:/, '').split('@'); const uid = await user.getUidByUserslug(slug); return { type: 'user', id: uid }; diff --git a/src/activitypub/mocks.js b/src/activitypub/mocks.js index ee3eb3b2a7..93f6acaa4f 100644 --- a/src/activitypub/mocks.js +++ b/src/activitypub/mocks.js @@ -228,7 +228,7 @@ Mocks.note = async (post) => { inReplyTo = utils.isNumber(post.topic.mainPid) ? `${nconf.get('url')}/post/${post.topic.mainPid}` : post.topic.mainPid; to.add(utils.isNumber(post.topic.uid) ? `${nconf.get('url')}/uid/${post.topic.uid}` : post.topic.uid); } else { // new topic - name = await topics.getTitleByPid(post.pid); + ({ titleRaw: name } = await topics.getTopicFields(post.tid, ['title'])); tag = post.topic.tags.map(tag => ({ type: 'Hashtag', href: `${nconf.get('url')}/tags/${tag.valueEncoded}`, diff --git a/test/activitypub.js b/test/activitypub.js index 104ec7c5e8..bdc88a4482 100644 --- a/test/activitypub.js +++ b/test/activitypub.js @@ -1,21 +1,20 @@ 'use strict'; const assert = require('assert'); -const { createHash } = require('crypto'); const nconf = require('nconf'); +const path = require('path'); const db = require('./mocks/databasemock'); const slugify = require('../src/slugify'); const utils = require('../src/utils'); const request = require('../src/request'); +const file = require('../src/file'); const install = require('../src/install'); const meta = require('../src/meta'); const user = require('../src/user'); const categories = require('../src/categories'); const topics = require('../src/topics'); -const posts = require('../src/posts'); -const privileges = require('../src/privileges'); const activitypub = require('../src/activitypub'); describe('ActivityPub integration', () => { @@ -28,59 +27,6 @@ describe('ActivityPub integration', () => { delete meta.config.activitypubEnabled; }); - describe('WebFinger endpoint', () => { - let uid; - let slug; - const { host } = nconf.get('url_parsed'); - - beforeEach(async () => { - slug = slugify(utils.generateUUID().slice(0, 8)); - uid = await user.create({ username: slug }); - }); - - it('should return a 404 Not Found if no user exists by that username', async () => { - const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3afoobar%40${host}`); - - assert(response); - assert.strictEqual(response.statusCode, 404); - }); - - it('should return a 400 Bad Request if the request is malformed', async () => { - const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3afoobar`); - - assert(response); - assert.strictEqual(response.statusCode, 400); - }); - - it('should return 403 Forbidden if the calling user is not allowed to view the user list/profiles', async () => { - await privileges.global.rescind(['groups:view:users'], 'guests'); - const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3a${slug}%40${host}`); - - assert(response); - assert.strictEqual(response.statusCode, 400); - await privileges.global.give(['groups:view:users'], 'guests'); - }); - - it('should return a valid WebFinger response otherwise', async () => { - const { response, body } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3a${slug}%40${host}`); - - assert(response); - assert.strictEqual(response.statusCode, 200); - - ['subject', 'aliases', 'links'].forEach((prop) => { - assert(body.hasOwnProperty(prop)); - assert(body[prop]); - }); - - assert.strictEqual(body.subject, `acct:${slug}@${host}`); - - assert(Array.isArray(body.aliases)); - assert([`${nconf.get('url')}/uid/${uid}`, `${nconf.get('url')}/user/${slug}`].every(url => body.aliases.includes(url))); - - assert(Array.isArray(body.links)); - }); - }); - describe('Helpers', () => { describe('.query()', () => { @@ -300,139 +246,6 @@ describe('ActivityPub integration', () => { }); }); - describe('http signature signing and verification', () => { - describe('.sign()', () => { - let uid; - - before(async () => { - uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); - }); - - it('should create a key-pair for a user if the user does not have one already', async () => { - const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; - const keyData = await activitypub.getPrivateKey('uid', uid); - await activitypub.sign(keyData, endpoint); - const { publicKey, privateKey } = await db.getObject(`uid:${uid}:keys`); - - assert(publicKey); - assert(privateKey); - }); - - it('should return an object with date, a null digest, and signature, if no payload is passed in', async () => { - const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; - const keyData = await activitypub.getPrivateKey('uid', uid); - const { date, digest, signature } = await activitypub.sign(keyData, endpoint); - const dateObj = new Date(date); - - assert(signature); - assert(dateObj); - assert.strictEqual(digest, null); - }); - - it('should also return a digest hash if payload is passed in', async () => { - const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; - const payload = { foo: 'bar' }; - const keyData = await activitypub.getPrivateKey('uid', uid); - const { digest } = await activitypub.sign(keyData, endpoint, payload); - const hash = createHash('sha256'); - hash.update(JSON.stringify(payload)); - const checksum = hash.digest('base64'); - - assert(digest); - assert.strictEqual(digest, `sha-256=${checksum}`); - }); - - it('should create a key for NodeBB itself if a uid of 0 is passed in', async () => { - const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; - const keyData = await activitypub.getPrivateKey('uid', 0); - await activitypub.sign(keyData, endpoint); - const { publicKey, privateKey } = await db.getObject(`uid:0:keys`); - - assert(publicKey); - assert(privateKey); - }); - - it('should return headers with an appropriate key id uri', async () => { - const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; - const keyData = await activitypub.getPrivateKey('uid', uid); - const { signature } = await activitypub.sign(keyData, endpoint); - const [keyId] = signature.split(','); - - assert(signature); - assert.strictEqual(keyId, `keyId="${nconf.get('url')}/uid/${uid}#key"`); - }); - - it('should return the instance key id when uid is 0', async () => { - const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; - const keyData = await activitypub.getPrivateKey('uid', 0); - const { signature } = await activitypub.sign(keyData, endpoint); - const [keyId] = signature.split(','); - - assert(signature); - assert.strictEqual(keyId, `keyId="${nconf.get('url')}/actor#key"`); - }); - }); - - describe('.verify()', () => { - let uid; - let username; - const baseUrl = nconf.get('relative_path'); - const mockReqBase = { - method: 'GET', - // path: ... - baseUrl, - headers: { - // host: ... - // date: ... - // signature: ... - // digest: ... - }, - }; - - before(async () => { - username = utils.generateUUID().slice(0, 10); - uid = await user.create({ username }); - }); - - it('should return true when the proper signature and relevant headers are passed in', async () => { - const endpoint = `${nconf.get('url')}/user/${username}/inbox`; - const path = `/user/${username}/inbox`; - const keyData = await activitypub.getPrivateKey('uid', uid); - const signature = await activitypub.sign(keyData, endpoint); - const { host } = nconf.get('url_parsed'); - const req = { - ...mockReqBase, - ...{ - path, - headers: { ...signature, host }, - }, - }; - - const verified = await activitypub.verify(req); - assert.strictEqual(verified, true); - }); - - it('should return true when a digest is also passed in', async () => { - const endpoint = `${nconf.get('url')}/user/${username}/inbox`; - const path = `/user/${username}/inbox`; - const keyData = await activitypub.getPrivateKey('uid', uid); - const signature = await activitypub.sign(keyData, endpoint, { foo: 'bar' }); - const { host } = nconf.get('url_parsed'); - const req = { - ...mockReqBase, - ...{ - method: 'POST', - path, - headers: { ...signature, host }, - }, - }; - - const verified = await activitypub.verify(req); - assert.strictEqual(verified, true); - }); - }); - }); - describe('Receipt of ActivityPub events to inboxes (federating IN)', () => { describe('Create', () => { describe('Note', () => { @@ -526,7 +339,7 @@ describe('ActivityPub integration', () => { ({ postData, topicData } = await topics.post({ uid, cid: category.cid, - title: 'Lipsum title', + title: 'Lorem "Lipsum" Ipsum', content: 'Lorem ipsum dolor sit amet', })); }); @@ -560,6 +373,10 @@ describe('ActivityPub integration', () => { it('should return the expected Content-Type header', () => { assert.strictEqual(response.headers['content-type'], 'application/activity+json; charset=utf-8'); }); + + it('Topic title (`name`) should not be escaped', () => { + assert.strictEqual(body.name, 'Lorem "Lipsum" Ipsum'); + }); }); }); @@ -637,4 +454,18 @@ describe('ActivityPub integration', () => { }); }); }); + + describe('ActivityPub', async () => { + let files; + + before(async () => { + files = await file.walk(path.resolve(__dirname, './activitypub')); + }); + + it('subfolder tests', () => { + files.forEach((filePath) => { + require(filePath); + }); + }); + }); }); diff --git a/test/activitypub/signatures.js b/test/activitypub/signatures.js new file mode 100644 index 0000000000..c05b24140b --- /dev/null +++ b/test/activitypub/signatures.js @@ -0,0 +1,143 @@ +'use strict'; + +const assert = require('assert'); +const nconf = require('nconf'); +const { createHash } = require('crypto'); + +const user = require('../../src/user'); +const utils = require('../../src/utils'); +const db = require('../../src/database'); +const activitypub = require('../../src/activitypub'); + +describe('http signature signing and verification', () => { + describe('.sign()', () => { + let uid; + + before(async () => { + uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + }); + + it('should create a key-pair for a user if the user does not have one already', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + await activitypub.sign(keyData, endpoint); + const { publicKey, privateKey } = await db.getObject(`uid:${uid}:keys`); + + assert(publicKey); + assert(privateKey); + }); + + it('should return an object with date, a null digest, and signature, if no payload is passed in', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + const { date, digest, signature } = await activitypub.sign(keyData, endpoint); + const dateObj = new Date(date); + + assert(signature); + assert(dateObj); + assert.strictEqual(digest, null); + }); + + it('should also return a digest hash if payload is passed in', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const payload = { foo: 'bar' }; + const keyData = await activitypub.getPrivateKey('uid', uid); + const { digest } = await activitypub.sign(keyData, endpoint, payload); + const hash = createHash('sha256'); + hash.update(JSON.stringify(payload)); + const checksum = hash.digest('base64'); + + assert(digest); + assert.strictEqual(digest, `SHA-256=${checksum}`); + }); + + it('should create a key for NodeBB itself if a uid of 0 is passed in', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', 0); + await activitypub.sign(keyData, endpoint); + const { publicKey, privateKey } = await db.getObject(`uid:0:keys`); + + assert(publicKey); + assert(privateKey); + }); + + it('should return headers with an appropriate key id uri', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + const { signature } = await activitypub.sign(keyData, endpoint); + const [keyId] = signature.split(','); + + assert(signature); + assert.strictEqual(keyId, `keyId="${nconf.get('url')}/uid/${uid}#key"`); + }); + + it('should return the instance key id when uid is 0', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', 0); + const { signature } = await activitypub.sign(keyData, endpoint); + const [keyId] = signature.split(','); + + assert(signature); + assert.strictEqual(keyId, `keyId="${nconf.get('url')}/actor#key"`); + }); + }); + + describe('.verify()', () => { + let uid; + let username; + const baseUrl = nconf.get('relative_path'); + const mockReqBase = { + method: 'GET', + // path: ... + baseUrl, + headers: { + // host: ... + // date: ... + // signature: ... + // digest: ... + }, + }; + + before(async () => { + username = utils.generateUUID().slice(0, 10); + uid = await user.create({ username }); + }); + + it('should return true when the proper signature and relevant headers are passed in', async () => { + const endpoint = `${nconf.get('url')}/user/${username}/inbox`; + const path = `/user/${username}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + const signature = await activitypub.sign(keyData, endpoint); + const { host } = nconf.get('url_parsed'); + const req = { + ...mockReqBase, + ...{ + path, + headers: { ...signature, host }, + }, + }; + + const verified = await activitypub.verify(req); + assert.strictEqual(verified, true); + }); + + it('should return true when a digest is also passed in', async () => { + const endpoint = `${nconf.get('url')}/user/${username}/inbox`; + const path = `/user/${username}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + const signature = await activitypub.sign(keyData, endpoint, { foo: 'bar' }); + const { host } = nconf.get('url_parsed'); + const req = { + ...mockReqBase, + ...{ + method: 'POST', + path, + headers: { ...signature, host }, + }, + }; + + const verified = await activitypub.verify(req); + assert.strictEqual(verified, true); + }); + }); +}); diff --git a/test/activitypub/webfinger.js b/test/activitypub/webfinger.js new file mode 100644 index 0000000000..9e503ebfc3 --- /dev/null +++ b/test/activitypub/webfinger.js @@ -0,0 +1,63 @@ +'use strict'; + +const assert = require('assert'); +const nconf = require('nconf'); + +const request = require('../../src/request'); +const utils = require('../../src/utils'); +const user = require('../../src/user'); +const slugify = require('../../src/slugify'); +const privileges = require('../../src/privileges'); + +describe('WebFinger endpoint', () => { + let uid; + let slug; + const { host } = nconf.get('url_parsed'); + + beforeEach(async () => { + slug = slugify(utils.generateUUID().slice(0, 8)); + uid = await user.create({ username: slug }); + }); + + it('should return a 404 Not Found if no user exists by that username', async () => { + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3afoobar%40${host}`); + + assert(response); + assert.strictEqual(response.statusCode, 404); + }); + + it('should return a 400 Bad Request if the request is malformed', async () => { + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3afoobar`); + + assert(response); + assert.strictEqual(response.statusCode, 400); + }); + + it('should return 403 Forbidden if the calling user is not allowed to view the user list/profiles', async () => { + await privileges.global.rescind(['groups:view:users'], 'guests'); + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3a${slug}%40${host}`); + + assert(response); + assert.strictEqual(response.statusCode, 400); + await privileges.global.give(['groups:view:users'], 'guests'); + }); + + it('should return a valid WebFinger response otherwise', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3a${slug}%40${host}`); + + assert(response); + assert.strictEqual(response.statusCode, 200); + + ['subject', 'aliases', 'links'].forEach((prop) => { + assert(body.hasOwnProperty(prop)); + assert(body[prop]); + }); + + assert.strictEqual(body.subject, `acct:${slug}@${host}`); + + assert(Array.isArray(body.aliases)); + assert([`${nconf.get('url')}/uid/${uid}`, `${nconf.get('url')}/user/${slug}`].every(url => body.aliases.includes(url))); + + assert(Array.isArray(body.links)); + }); +});