Skip to content

Commit 1e98033

Browse files
authored
Security: Fix revision parsing (#5772)
A carefully crated URL can cause Etherpad to hang.
1 parent 1d28952 commit 1e98033

File tree

9 files changed

+325
-29
lines changed

9 files changed

+325
-29
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
# Next release
2+
3+
### Notable enhancements and fixes
4+
5+
* Security
6+
* Limit requested revisions in timeslider and export to head revision. (affects v1.9.0)
7+
8+
* Bugfixes
9+
* revisions in `CHANGESET_REQ` (timeslider) and export (txt, html, custom)
10+
are now checked to be numbers.
11+
112
# 1.9.0
213

314
### Notable enhancements and fixes

src/node/db/API.js

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const exportTxt = require('../utils/ExportTxt');
3333
const importHtml = require('../utils/ImportHtml');
3434
const cleanText = require('./Pad').cleanText;
3535
const PadDiff = require('../utils/padDiff');
36+
const { checkValidRev, isInt } = require('../utils/checkValidRev');
3637

3738
/* ********************
3839
* GROUP FUNCTIONS ****
@@ -777,6 +778,13 @@ exports.createDiffHTML = async (padID, startRev, endRev) => {
777778

778779
// get the pad
779780
const pad = await getPadSafe(padID, true);
781+
const headRev = pad.getHeadRevisionNumber();
782+
if (startRev > headRev)
783+
startRev = headRev;
784+
785+
if (endRev > headRev)
786+
endRev = headRev;
787+
780788
let padDiff;
781789
try {
782790
padDiff = new PadDiff(pad, startRev, endRev);
@@ -822,9 +830,6 @@ exports.getStats = async () => {
822830
** INTERNAL HELPER FUNCTIONS *
823831
**************************** */
824832

825-
// checks if a number is an int
826-
const isInt = (value) => (parseFloat(value) === parseInt(value, 10)) && !isNaN(value);
827-
828833
// gets a pad safe
829834
const getPadSafe = async (padID, shouldExist, text, authorId = '') => {
830835
// check if padID is a string
@@ -854,31 +859,6 @@ const getPadSafe = async (padID, shouldExist, text, authorId = '') => {
854859
return padManager.getPad(padID, text, authorId);
855860
};
856861

857-
// checks if a rev is a legal number
858-
// pre-condition is that `rev` is not undefined
859-
const checkValidRev = (rev) => {
860-
if (typeof rev !== 'number') {
861-
rev = parseInt(rev, 10);
862-
}
863-
864-
// check if rev is a number
865-
if (isNaN(rev)) {
866-
throw new CustomError('rev is not a number', 'apierror');
867-
}
868-
869-
// ensure this is not a negative number
870-
if (rev < 0) {
871-
throw new CustomError('rev is not a negative number', 'apierror');
872-
}
873-
874-
// ensure this is not a float value
875-
if (!isInt(rev)) {
876-
throw new CustomError('rev is a float value', 'apierror');
877-
}
878-
879-
return rev;
880-
};
881-
882862
// checks if a padID is part of a group
883863
const checkGroupPad = (padID, field) => {
884864
// ensure this is a group pad

src/node/db/Pad.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ class Pad {
172172

173173
async getInternalRevisionAText(targetRev) {
174174
const keyRev = this.getKeyRevisionNumber(targetRev);
175+
const headRev = this.getHeadRevisionNumber();
176+
if (targetRev > headRev)
177+
targetRev = headRev;
175178
const [keyAText, changesets] = await Promise.all([
176179
this._getKeyRevisionAText(keyRev),
177180
Promise.all(

src/node/handler/ExportHandler.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const os = require('os');
2929
const hooks = require('../../static/js/pluginfw/hooks');
3030
const TidyHtml = require('../utils/TidyHtml');
3131
const util = require('util');
32+
const { checkValidRev } = require('../utils/checkValidRev');
3233

3334
const fsp_writeFile = util.promisify(fs.writeFile);
3435
const fsp_unlink = util.promisify(fs.unlink);
@@ -53,6 +54,12 @@ exports.doExport = async (req, res, padId, readOnlyId, type) => {
5354
// tell the browser that this is a downloadable file
5455
res.attachment(`${fileName}.${type}`);
5556

57+
if (req.params.rev !== undefined) {
58+
// ensure revision is a number
59+
// modify req, as we use it in a later call to exportConvert
60+
req.params.rev = checkValidRev(req.params.rev);
61+
}
62+
5663
// if this is a plain text export, we can do this directly
5764
// We have to over engineer this because tabs are stored as attributes and not plain text
5865
if (type === 'etherpad') {

src/node/handler/PadMessageHandler.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const stats = require('../stats');
3939
const assert = require('assert').strict;
4040
const {RateLimiterMemory} = require('rate-limiter-flexible');
4141
const webaccess = require('../hooks/express/webaccess');
42+
const { checkValidRev } = require('../utils/checkValidRev');
4243

4344
let rateLimiter;
4445
let socketio = null;
@@ -1076,10 +1077,14 @@ const handleChangesetRequest = async (socket, {data: {granularity, start, reques
10761077
if (granularity == null) throw new Error('missing granularity');
10771078
if (!Number.isInteger(granularity)) throw new Error('granularity is not an integer');
10781079
if (start == null) throw new Error('missing start');
1080+
start = checkValidRev(start);
10791081
if (requestID == null) throw new Error('mising requestID');
10801082
const end = start + (100 * granularity);
10811083
const {padId, author: authorId} = sessioninfos[socket.id];
10821084
const pad = await padManager.getPad(padId, null, authorId);
1085+
const headRev = pad.getHeadRevisionNumber();
1086+
if (start > headRev)
1087+
start = headRev;
10831088
const data = await getChangesetInfo(pad, start, end, granularity);
10841089
data.requestID = requestID;
10851090
socket.json.send({type: 'CHANGESET_REQ', data});

src/node/utils/ExportHelper.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@
2121

2222
const AttributeMap = require('../../static/js/AttributeMap');
2323
const Changeset = require('../../static/js/Changeset');
24+
const { checkValidRev } = require('./checkValidRev');
2425

26+
/*
27+
* This method seems unused in core and no plugins depend on it
28+
*/
2529
exports.getPadPlainText = (pad, revNum) => {
2630
const _analyzeLine = exports._analyzeLine;
27-
const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(revNum) : pad.atext);
31+
const atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(checkValidRev(revNum)) : pad.atext);
2832
const textLines = atext.text.slice(0, -1).split('\n');
2933
const attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text);
3034
const apool = pad.pool;

src/node/utils/checkValidRev.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use strict';
2+
3+
const CustomError = require('../utils/customError');
4+
5+
// checks if a rev is a legal number
6+
// pre-condition is that `rev` is not undefined
7+
const checkValidRev = (rev) => {
8+
if (typeof rev !== 'number') {
9+
rev = parseInt(rev, 10);
10+
}
11+
12+
// check if rev is a number
13+
if (isNaN(rev)) {
14+
throw new CustomError('rev is not a number', 'apierror');
15+
}
16+
17+
// ensure this is not a negative number
18+
if (rev < 0) {
19+
throw new CustomError('rev is not a negative number', 'apierror');
20+
}
21+
22+
// ensure this is not a float value
23+
if (!isInt(rev)) {
24+
throw new CustomError('rev is a float value', 'apierror');
25+
}
26+
27+
return rev;
28+
};
29+
30+
// checks if a number is an int
31+
const isInt = (value) => (parseFloat(value) === parseInt(value, 10)) && !isNaN(value);
32+
33+
exports.isInt = isInt;
34+
exports.checkValidRev = checkValidRev;

src/tests/backend/specs/api/importexportGetPost.js

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,175 @@ describe(__filename, function () {
447447
});
448448
});
449449

450+
describe('revisions are supported in txt and html export', function () {
451+
const makeGoodExport = () => ({
452+
'pad:testing': {
453+
atext: {
454+
text: 'oofoo\n',
455+
attribs: '|1+6',
456+
},
457+
pool: {
458+
numToAttrib: {
459+
0: ['author', 'a.foo'],
460+
},
461+
nextNum: 1,
462+
},
463+
head: 2,
464+
savedRevisions: [],
465+
},
466+
'globalAuthor:a.foo': {
467+
colorId: '#000000',
468+
name: 'author foo',
469+
timestamp: 1598747784631,
470+
padIDs: 'testing',
471+
},
472+
'pad:testing:revs:0': {
473+
changeset: 'Z:1>3+3$foo',
474+
meta: {
475+
author: 'a.foo',
476+
timestamp: 1597632398288,
477+
pool: {
478+
nextNum: 1,
479+
numToAttrib: {
480+
0: ['author', 'a.foo'],
481+
},
482+
},
483+
atext: {
484+
text: 'foo\n',
485+
attribs: '|1+4',
486+
},
487+
},
488+
},
489+
'pad:testing:revs:1': {
490+
changeset: 'Z:4>1+1$o',
491+
meta: {
492+
author: 'a.foo',
493+
timestamp: 1597632398288,
494+
pool: {
495+
nextNum: 1,
496+
numToAttrib: {
497+
0: ['author', 'a.foo'],
498+
},
499+
},
500+
atext: {
501+
text: 'fooo\n',
502+
attribs: '*0|1+5',
503+
},
504+
},
505+
},
506+
'pad:testing:revs:2': {
507+
changeset: 'Z:5>1+1$o',
508+
meta: {
509+
author: 'a.foo',
510+
timestamp: 1597632398288,
511+
pool: {
512+
numToAttrib: {},
513+
nextNum: 0,
514+
},
515+
atext: {
516+
text: 'foooo\n',
517+
attribs: '*0|1+6',
518+
},
519+
},
520+
},
521+
});
522+
523+
const importEtherpad = (records) => agent.post(`/p/${testPadId}/import`)
524+
.attach('file', Buffer.from(JSON.stringify(records), 'utf8'), {
525+
filename: '/test.etherpad',
526+
contentType: 'application/etherpad',
527+
});
528+
529+
before(async function () {
530+
// makeGoodExport() is assumed to produce good .etherpad records. Verify that assumption so
531+
// that a buggy makeGoodExport() doesn't cause checks to accidentally pass.
532+
const records = makeGoodExport();
533+
await deleteTestPad();
534+
await importEtherpad(records)
535+
.expect(200)
536+
.expect('Content-Type', /json/)
537+
.expect((res) => assert.deepEqual(res.body, {
538+
code: 0,
539+
message: 'ok',
540+
data: {directDatabaseAccess: true},
541+
}));
542+
await agent.get(`/p/${testPadId}/export/txt`)
543+
.expect(200)
544+
.buffer(true).parse(superagent.parse.text)
545+
.expect((res) => assert.equal(res.text, 'oofoo\n'));
546+
});
547+
548+
it('txt request rev 1', async function () {
549+
await agent.get(`/p/${testPadId}/1/export/txt`)
550+
.expect(200)
551+
.buffer(true).parse(superagent.parse.text)
552+
.expect((res) => assert.equal(res.text, 'ofoo\n'));
553+
});
554+
555+
it('txt request rev 2', async function () {
556+
await agent.get(`/p/${testPadId}/2/export/txt`)
557+
.expect(200)
558+
.buffer(true).parse(superagent.parse.text)
559+
.expect((res) => assert.equal(res.text, 'oofoo\n'));
560+
});
561+
562+
it('txt request rev 1test returns rev 1', async function () {
563+
await agent.get(`/p/${testPadId}/1test/export/txt`)
564+
.expect(200)
565+
.buffer(true).parse(superagent.parse.text)
566+
.expect((res) => assert.equal(res.text, 'ofoo\n'));
567+
});
568+
569+
it('txt request rev test1 is 403', async function () {
570+
await agent.get(`/p/${testPadId}/test1/export/txt`)
571+
.expect(500)
572+
.buffer(true).parse(superagent.parse.text)
573+
.expect((res) => assert.match(res.text, /rev is not a number/));
574+
});
575+
576+
it('txt request rev 5 returns head rev', async function () {
577+
await agent.get(`/p/${testPadId}/5/export/txt`)
578+
.expect(200)
579+
.buffer(true).parse(superagent.parse.text)
580+
.expect((res) => assert.equal(res.text, 'oofoo\n'));
581+
});
582+
583+
it('html request rev 1', async function () {
584+
await agent.get(`/p/${testPadId}/1/export/html`)
585+
.expect(200)
586+
.buffer(true).parse(superagent.parse.text)
587+
.expect((res) => assert.match(res.text, /ofoo<br>/));
588+
});
589+
590+
it('html request rev 2', async function () {
591+
await agent.get(`/p/${testPadId}/2/export/html`)
592+
.expect(200)
593+
.buffer(true).parse(superagent.parse.text)
594+
.expect((res) => assert.match(res.text, /oofoo<br>/));
595+
});
596+
597+
it('html request rev 1test returns rev 1', async function () {
598+
await agent.get(`/p/${testPadId}/1test/export/html`)
599+
.expect(200)
600+
.buffer(true).parse(superagent.parse.text)
601+
.expect((res) => assert.match(res.text, /ofoo<br>/));
602+
});
603+
604+
it('html request rev test1 results in 500 response', async function () {
605+
await agent.get(`/p/${testPadId}/test1/export/html`)
606+
.expect(500)
607+
.buffer(true).parse(superagent.parse.text)
608+
.expect((res) => assert.match(res.text, /rev is not a number/));
609+
});
610+
611+
it('html request rev 5 returns head rev', async function () {
612+
await agent.get(`/p/${testPadId}/5/export/html`)
613+
.expect(200)
614+
.buffer(true).parse(superagent.parse.text)
615+
.expect((res) => assert.match(res.text, /oofoo<br>/));
616+
});
617+
});
618+
450619
describe('Import authorization checks', function () {
451620
let authorize;
452621

0 commit comments

Comments
 (0)