Skip to content

Commit 02a56dc

Browse files
committed
PadMessageHandler: Allow handleMessageSecurity to grant one-time write access
1 parent 31b025b commit 02a56dc

File tree

5 files changed

+94
-35
lines changed

5 files changed

+94
-35
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
* The `handleMessageSecurity` and `handleMessage` server-side hooks have a new
3636
`sessionInfo` context property that includes the user's author ID, the pad ID,
3737
and whether the user only has read-only access.
38+
* The `handleMessageSecurity` server-side hook can now be used to grant write
39+
access for the current message only.
3840

3941
### Compatibility changes
4042

@@ -43,6 +45,8 @@
4345
* The `client` context property for the `handleMessageSecurity` and
4446
`handleMessage` server-side hooks is deprecated; use the `socket` context
4547
property instead.
48+
* Returning `true` from a `handleMessageSecurity` hook function is deprecated;
49+
return `'permitOnce'` instead.
4650
* Changes to the `src/static/js/Changeset.js` library:
4751
* The following attribute processing functions are deprecated (use the new
4852
attribute APIs instead):

doc/api/hooks_server-side.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -616,12 +616,15 @@ temporary write access to a pad.
616616
Supported return values:
617617

618618
* `undefined`: No change in access status.
619-
* `true`: Override the user's read-only access for all `COLLABROOM` messages
620-
from the same socket.io connection (including the current message, if
621-
applicable) until the client's next `CLIENT_READY` message. Has no effect if
622-
the user already has write access to the pad. Read-only access is reset
623-
**after** each `CLIENT_READY` message, so returning `true` has no effect for
624-
`CLIENT_READY` messages.
619+
* `'permitOnce'`: Override the user's read-only access for the current
620+
`COLLABROOM` message only. Has no effect if the current message is not a
621+
`COLLABROOM` message, or if the user already has write access to the pad.
622+
* `true`: (**Deprecated**; return `'permitOnce'` instead.) Override the user's
623+
read-only access for all `COLLABROOM` messages from the same socket.io
624+
connection (including the current message, if applicable) until the client's
625+
next `CLIENT_READY` message. Has no effect if the user already has write
626+
access to the pad. Read-only access is reset **after** each `CLIENT_READY`
627+
message, so returning `true` has no effect for `CLIENT_READY` messages.
625628

626629
Context properties:
627630

@@ -639,9 +642,9 @@ Example:
639642

640643
```javascript
641644
exports.handleMessageSecurity = async (hookName, context) => {
642-
const {message, sessionInfo: {readOnly}, socket} = context;
645+
const {message, sessionInfo: {readOnly}} = context;
643646
if (!readOnly || message.type !== 'COLLABROOM') return;
644-
if (shouldGrantWriteAccess(message, socket)) return true;
647+
if (await messageIsBenign(message)) return 'permitOnce';
645648
};
646649
```
647650

src/node/handler/PadMessageHandler.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ exports.handleMessage = async (socket, message) => {
276276
thisSession.author = authorID;
277277

278278
// Allow plugins to bypass the readonly message blocker
279+
let readOnly = thisSession.readonly;
279280
const context = {
280281
message,
281282
sessionInfo: {
@@ -291,8 +292,21 @@ exports.handleMessage = async (socket, message) => {
291292
return this.socket;
292293
},
293294
};
294-
if ((await hooks.aCallAll('handleMessageSecurity', context)).some((w) => w === true)) {
295-
thisSession.readonly = false;
295+
for (const res of await hooks.aCallAll('handleMessageSecurity', context)) {
296+
switch (res) {
297+
case true:
298+
padutils.warnDeprecated(
299+
'returning `true` from a `handleMessageSecurity` hook function is deprecated; ' +
300+
'return "permitOnce" instead');
301+
thisSession.readonly = false;
302+
// Fall through:
303+
case 'permitOnce':
304+
readOnly = false;
305+
break;
306+
default:
307+
messageLogger.warn(
308+
'Ignoring unsupported return value from handleMessageSecurity hook function:', res);
309+
}
296310
}
297311

298312
// Call handleMessage hook. If a plugin returns null, the message will be dropped.
@@ -312,7 +326,7 @@ exports.handleMessage = async (socket, message) => {
312326
} else if (message.type === 'CHANGESET_REQ') {
313327
await handleChangesetRequest(socket, message);
314328
} else if (message.type === 'COLLABROOM') {
315-
if (thisSession.readonly) {
329+
if (readOnly) {
316330
messageLogger.warn('Dropped message, COLLABROOM for readonly pad');
317331
} else if (message.data.type === 'USER_CHANGES') {
318332
stats.counter('pendingEdits').inc();

src/tests/backend/common.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,14 @@ exports.connect = async (res = null) => {
172172
* @param {string} padId - Which pad to join.
173173
* @returns The CLIENT_VARS message from the server.
174174
*/
175-
exports.handshake = async (socket, padId) => {
175+
exports.handshake = async (socket, padId, token = 't.12345') => {
176176
logger.debug('sending CLIENT_READY...');
177177
socket.send({
178178
component: 'pad',
179179
type: 'CLIENT_READY',
180180
padId,
181181
sessionID: null,
182-
token: 't.12345',
182+
token,
183183
});
184184
logger.debug('waiting for CLIENT_VARS response...');
185185
const msg = await exports.waitForSocketEvent(socket, 'message');
Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,141 @@
11
'use strict';
22

3-
const AttributePool = require('../../../static/js/AttributePool');
43
const assert = require('assert').strict;
54
const common = require('../common');
65
const padManager = require('../../../node/db/PadManager');
6+
const plugins = require('../../../static/js/pluginfw/plugin_defs');
7+
const readOnlyManager = require('../../../node/db/ReadOnlyManager');
78

89
describe(__filename, function () {
910
let agent;
1011
let pad;
1112
let padId;
13+
let roPadId;
1214
let rev;
1315
let socket;
16+
let roSocket;
17+
const backups = {};
1418

1519
before(async function () {
1620
agent = await common.init();
1721
});
1822

1923
beforeEach(async function () {
24+
backups.hooks = {handleMessageSecurity: plugins.hooks.handleMessageSecurity};
25+
plugins.hooks.handleMessageSecurity = [];
2026
padId = common.randomString();
2127
assert(!await padManager.doesPadExist(padId));
22-
pad = await padManager.getPad(padId, '');
28+
pad = await padManager.getPad(padId, 'dummy text');
29+
await pad.setText('\n'); // Make sure the pad is created.
2330
assert.equal(pad.text(), '\n');
24-
const res = await agent.get(`/p/${padId}`).expect(200);
31+
let res = await agent.get(`/p/${padId}`).expect(200);
2532
socket = await common.connect(res);
2633
const {type, data: clientVars} = await common.handshake(socket, padId);
2734
assert.equal(type, 'CLIENT_VARS');
2835
rev = clientVars.collab_client_vars.rev;
36+
37+
roPadId = await readOnlyManager.getReadOnlyId(padId);
38+
res = await agent.get(`/p/${roPadId}`).expect(200);
39+
roSocket = await common.connect(res);
40+
await common.handshake(roSocket, roPadId, `t.${common.randomString(8)}`);
2941
});
3042

3143
afterEach(async function () {
44+
Object.assign(plugins.hooks, backups.hooks);
3245
if (socket != null) socket.close();
3346
socket = null;
47+
if (roSocket != null) roSocket.close();
48+
roSocket = null;
3449
if (pad != null) await pad.remove();
3550
pad = null;
3651
});
3752

3853
describe('USER_CHANGES', function () {
3954
const sendUserChanges =
40-
async (changeset) => await common.sendUserChanges(socket, {baseRev: rev, changeset});
41-
const assertAccepted = async (wantRev) => {
55+
async (socket, cs) => await common.sendUserChanges(socket, {baseRev: rev, changeset: cs});
56+
const assertAccepted = async (socket, wantRev) => {
4257
await common.waitForAcceptCommit(socket, wantRev);
4358
rev = wantRev;
4459
};
45-
const assertRejected = async () => {
60+
const assertRejected = async (socket) => {
4661
const msg = await common.waitForSocketEvent(socket, 'message');
4762
assert.deepEqual(msg, {disconnect: 'badChangeset'});
4863
};
4964

5065
it('changes are applied', async function () {
5166
await Promise.all([
52-
assertAccepted(rev + 1),
53-
sendUserChanges('Z:1>5+5$hello'),
67+
assertAccepted(socket, rev + 1),
68+
sendUserChanges(socket, 'Z:1>5+5$hello'),
5469
]);
5570
assert.equal(pad.text(), 'hello\n');
5671
});
5772

5873
it('bad changeset is rejected', async function () {
5974
await Promise.all([
60-
assertRejected(),
61-
sendUserChanges('this is not a valid changeset'),
75+
assertRejected(socket),
76+
sendUserChanges(socket, 'this is not a valid changeset'),
6277
]);
6378
});
6479

6580
it('retransmission is accepted, has no effect', async function () {
6681
const cs = 'Z:1>5+5$hello';
6782
await Promise.all([
68-
assertAccepted(rev + 1),
69-
sendUserChanges(cs),
83+
assertAccepted(socket, rev + 1),
84+
sendUserChanges(socket, cs),
7085
]);
7186
--rev;
7287
await Promise.all([
73-
assertAccepted(rev + 1),
74-
sendUserChanges(cs),
88+
assertAccepted(socket, rev + 1),
89+
sendUserChanges(socket, cs),
7590
]);
7691
assert.equal(pad.text(), 'hello\n');
7792
});
7893

7994
it('identity changeset is accepted, has no effect', async function () {
8095
await Promise.all([
81-
assertAccepted(rev + 1),
82-
sendUserChanges('Z:1>5+5$hello'),
96+
assertAccepted(socket, rev + 1),
97+
sendUserChanges(socket, 'Z:1>5+5$hello'),
8398
]);
8499
await Promise.all([
85-
assertAccepted(rev),
86-
sendUserChanges('Z:6>0$'),
100+
assertAccepted(socket, rev),
101+
sendUserChanges(socket, 'Z:6>0$'),
87102
]);
88103
assert.equal(pad.text(), 'hello\n');
89104
});
90105

91106
it('non-identity changeset with no net change is accepted, has no effect', async function () {
92107
await Promise.all([
93-
assertAccepted(rev + 1),
94-
sendUserChanges('Z:1>5+5$hello'),
108+
assertAccepted(socket, rev + 1),
109+
sendUserChanges(socket, 'Z:1>5+5$hello'),
110+
]);
111+
await Promise.all([
112+
assertAccepted(socket, rev),
113+
sendUserChanges(socket, 'Z:6>0-5+5$hello'),
95114
]);
115+
assert.equal(pad.text(), 'hello\n');
116+
});
117+
118+
it('handleMessageSecurity can grant one-time write access', async function () {
119+
const cs = 'Z:1>5+5$hello';
120+
// First try to send a change and verify that it was dropped.
121+
await sendUserChanges(roSocket, cs);
122+
// sendUserChanges() waits for message ack, so if the message was accepted then head should
123+
// have already incremented by the time we get here.
124+
assert.equal(pad.head, rev); // Not incremented.
125+
126+
// Now allow the change.
127+
plugins.hooks.handleMessageSecurity.push({hook_fn: () => 'permitOnce'});
96128
await Promise.all([
97-
assertAccepted(rev),
98-
sendUserChanges('Z:6>0-5+5$hello'),
129+
assertAccepted(roSocket, rev + 1),
130+
sendUserChanges(roSocket, cs),
99131
]);
100132
assert.equal(pad.text(), 'hello\n');
133+
134+
// The next change should be dropped.
135+
plugins.hooks.handleMessageSecurity = [];
136+
await sendUserChanges(roSocket, 'Z:6>6=5+6$ world');
137+
assert.equal(pad.head, rev); // Not incremented.
138+
assert.equal(pad.text(), 'hello\n');
101139
});
102140
});
103141
});

0 commit comments

Comments
 (0)