|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +// #222: comment edit/delete permissions are configurable. By default only the |
| 4 | +// original author may edit/delete a comment (restrictive, from #163); setting |
| 5 | +// `ep_comments_page.allowAnyoneToEditComments` switches to a permissive model. |
| 6 | +const assert = require('assert').strict; |
| 7 | +const common = require('ep_etherpad-lite/tests/backend/common'); |
| 8 | +const settings = require('ep_etherpad-lite/node/utils/Settings'); |
| 9 | +const commentManager = require('ep_comments_page/commentManager'); |
| 10 | +const epComments = require('ep_comments_page'); |
| 11 | +const authorManager = |
| 12 | + require('ep_etherpad-lite/node/db/AuthorManager').default || |
| 13 | + require('ep_etherpad-lite/node/db/AuthorManager'); |
| 14 | + |
| 15 | +// Build a minimal fake /comment socket carrying a given author token in its |
| 16 | +// handshake Cookie header (the same place core reads it from). |
| 17 | +const socketWithToken = (token) => ({request: {headers: {cookie: `token=${token}`}}}); |
| 18 | + |
| 19 | +describe(__filename, function () { |
| 20 | + let savedSetting; |
| 21 | + |
| 22 | + before(async function () { await common.init(); }); |
| 23 | + beforeEach(async function () { savedSetting = settings.ep_comments_page; }); |
| 24 | + afterEach(async function () { settings.ep_comments_page = savedSetting; }); |
| 25 | + |
| 26 | + it('by default only the original author may delete a comment', async function () { |
| 27 | + settings.ep_comments_page = {}; |
| 28 | + const padId = 'ep_comments_test_perms_default'; |
| 29 | + const [commentId] = |
| 30 | + await commentManager.addComment(padId, {author: 'a.AUTHOR1', text: 'hello'}); |
| 31 | + |
| 32 | + await assert.rejects( |
| 33 | + commentManager.deleteComment(padId, commentId, 'a.AUTHOR2'), |
| 34 | + /unauth/, |
| 35 | + 'a different author must not be able to delete the comment by default'); |
| 36 | + |
| 37 | + // The original author still can. |
| 38 | + await commentManager.deleteComment(padId, commentId, 'a.AUTHOR1'); |
| 39 | + }); |
| 40 | + |
| 41 | + it('by default only the original author may edit a comment', async function () { |
| 42 | + settings.ep_comments_page = {}; |
| 43 | + const padId = 'ep_comments_test_perms_default_edit'; |
| 44 | + const [commentId] = |
| 45 | + await commentManager.addComment(padId, {author: 'a.AUTHOR1', text: 'hello'}); |
| 46 | + |
| 47 | + await assert.rejects( |
| 48 | + commentManager.changeCommentText(padId, commentId, 'hacked', 'a.AUTHOR2'), |
| 49 | + /unauth/); |
| 50 | + }); |
| 51 | + |
| 52 | + it('allowAnyoneToEditComments lets any author edit and delete', async function () { |
| 53 | + settings.ep_comments_page = {allowAnyoneToEditComments: true}; |
| 54 | + const padId = 'ep_comments_test_perms_permissive'; |
| 55 | + const [commentId] = |
| 56 | + await commentManager.addComment(padId, {author: 'a.AUTHOR1', text: 'hello'}); |
| 57 | + |
| 58 | + // A different author can edit... |
| 59 | + await commentManager.changeCommentText(padId, commentId, 'edited by someone else', 'a.AUTHOR2'); |
| 60 | + const comments = await commentManager.getComments(padId); |
| 61 | + assert.equal(comments.comments[commentId].text, 'edited by someone else'); |
| 62 | + |
| 63 | + // ...and delete. |
| 64 | + await commentManager.deleteComment(padId, commentId, 'a.AUTHOR2'); |
| 65 | + const after = await commentManager.getComments(padId); |
| 66 | + assert.equal(after.comments[commentId], undefined); |
| 67 | + }); |
| 68 | + |
| 69 | + // #222 (security): the authorId used for authorization is resolved server-side |
| 70 | + // from the HttpOnly token cookie on the /comment socket handshake, NOT from |
| 71 | + // the client payload — so a client cannot spoof another user's authorId. |
| 72 | + describe('author identity is resolved from the token cookie, not the client', function () { |
| 73 | + it('resolves a token cookie to its owning author', async function () { |
| 74 | + const tokenA = `t.${common.randomString()}`; |
| 75 | + const authorA = await authorManager.getAuthorId(tokenA, {}); |
| 76 | + const resolved = await epComments.authorIdForSocket(socketWithToken(tokenA)); |
| 77 | + assert.equal(resolved, authorA, 'must resolve the cookie token to its author'); |
| 78 | + }); |
| 79 | + |
| 80 | + it('a different token resolves to a different author (no impersonation)', async function () { |
| 81 | + const tokenA = `t.${common.randomString()}`; |
| 82 | + const tokenB = `t.${common.randomString()}`; |
| 83 | + const authorA = await authorManager.getAuthorId(tokenA, {}); |
| 84 | + const resolvedFromB = await epComments.authorIdForSocket(socketWithToken(tokenB)); |
| 85 | + assert.notEqual(resolvedFromB, authorA, |
| 86 | + 'holding a different token must not yield author A\'s id'); |
| 87 | + }); |
| 88 | + |
| 89 | + it('fails closed (null) when no token cookie is present', async function () { |
| 90 | + assert.equal(await epComments.authorIdForSocket({request: {headers: {}}}), null); |
| 91 | + assert.equal(await epComments.authorIdForSocket({}), null); |
| 92 | + }); |
| 93 | + |
| 94 | + it('a spoofed authorId cannot delete another author\'s comment (end to end)', |
| 95 | + async function () { |
| 96 | + settings.ep_comments_page = {}; |
| 97 | + // Author A (identified by token A) owns the comment. |
| 98 | + const tokenA = `t.${common.randomString()}`; |
| 99 | + const authorA = await authorManager.getAuthorId(tokenA, {}); |
| 100 | + const padId = 'ep_comments_test_token_authz'; |
| 101 | + const [commentId] = |
| 102 | + await commentManager.addComment(padId, {author: authorA, text: 'hello'}); |
| 103 | + |
| 104 | + // Attacker holds token B but tries to act as author A. The server |
| 105 | + // resolves the *attacker's* author from their cookie, ignoring any |
| 106 | + // client-claimed authorId, so the delete is rejected. |
| 107 | + const tokenB = `t.${common.randomString()}`; |
| 108 | + const attackerAuthor = await epComments.authorIdForSocket(socketWithToken(tokenB)); |
| 109 | + await assert.rejects( |
| 110 | + commentManager.deleteComment(padId, commentId, attackerAuthor), /unauth/); |
| 111 | + |
| 112 | + // Author A (resolved from token A) can delete their own comment. |
| 113 | + const ownerAuthor = await epComments.authorIdForSocket(socketWithToken(tokenA)); |
| 114 | + assert.equal(ownerAuthor, authorA); |
| 115 | + await commentManager.deleteComment(padId, commentId, ownerAuthor); |
| 116 | + }); |
| 117 | + }); |
| 118 | +}); |
0 commit comments