Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# 3.3.12

3.3.12 is a security release. It closes an open redirect in the timeslider's "Return to pad" button (GHSA-35rj-7vrx-29wq) and stops revision-bound `.etherpad` exports from leaking later chat.

### Security

- **Timeslider — "Return to pad" no longer follows a cross-origin referrer (GHSA-35rj-7vrx-29wq).** The button navigated to `document.referrer` whenever the character before its last `/` was `p`, so a link from `https://attacker/p/login` to `/p/<pad>/timeslider?embed=1` sent the user back to the attacker's site when they clicked it. The referrer is now only followed when it is a pad page on the same origin; otherwise the button returns to the pad. Reported by @sun1024.
- **Export — revision-bound `.etherpad` exports only include chat up to that revision.** Exporting `/p/<pad>/<rev>/export/etherpad` shipped the pad's entire chat history, including messages posted after that revision. Reported by @temantih.

# 3.3.11

3.3.11 is a security release. It stops clients that connect without an author token from all sharing one author (GHSA-746x-9w89-7xv6).
Expand Down
2 changes: 1 addition & 1 deletion admin/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "3.3.10",
"version": "3.3.11",
"type": "module",
"scripts": {
"dev": "pnpm gen:api && vite",
Expand Down
2 changes: 1 addition & 1 deletion bin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bin",
"version": "3.3.10",
"version": "3.3.11",
"description": "",
"main": "checkAllPads.js",
"directories": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,6 @@
"url": "https://github.com/ether/etherpad.git"
},
"engineStrict": true,
"version": "3.3.10",
"version": "3.3.11",
"license": "Apache-2.0"
}
2 changes: 1 addition & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,6 @@
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
"test:vitest": "vitest"
},
"version": "3.3.10",
"version": "3.3.11",
"license": "Apache-2.0"
}
10 changes: 2 additions & 8 deletions src/static/js/pad_editbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

const hooks = require('./pluginfw/hooks');
import {getHomeUrl} from './getHomeUrl';
import {getReturnToPadUrl} from './timesliderReturnUrl';
import padutils from "./pad_utils";
const padeditor = require('./pad_editor').padeditor;
const padsavedrevs = require('./pad_savedrevs');
Expand Down Expand Up @@ -603,14 +604,7 @@ exports.padeditbar = new class {
});

this.registerCommand('timeslider_returnToPad', (cmd) => {
if (document.referrer.length > 0 &&
document.referrer.substring(document.referrer.lastIndexOf('/') - 1,
document.referrer.lastIndexOf('/')) === 'p') {
document.location = document.referrer;
} else {
document.location = document.location.href
.substring(0, document.location.href.lastIndexOf('/'));
}
document.location = getReturnToPadUrl(document.referrer, document.location.href);
});
}
}();
25 changes: 25 additions & 0 deletions src/static/js/timesliderReturnUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use strict';

/**
* Resolve where the timeslider's "Return to pad" button navigates to.
*
* The referrer is only followed when it is this timeslider's own pad page on
* the same origin (so its query string, e.g. `?showChat=false`, is kept).
* Anything else, including a cross-origin referrer that merely has a `/p/`
* path, falls back to the pad the timeslider belongs to
* (`{prefix}/p/{padId}/timeslider` -> `{prefix}/p/{padId}`). See
* GHSA-35rj-7vrx-29wq.
*/
export const getReturnToPadUrl = (referrer: string, timesliderHref: string): string => {
const pad = new URL(timesliderHref);
pad.pathname = pad.pathname.substring(0, pad.pathname.lastIndexOf('/'));
pad.search = '';
pad.hash = '';
try {
const ref = new URL(referrer);
if (ref.origin === pad.origin && ref.pathname === pad.pathname) return ref.href;
} catch {
// Empty or unparsable referrer.
}
return pad.href;
};
43 changes: 43 additions & 0 deletions src/tests/backend-new/specs/timesliderReturnUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict';

import {describe, it, expect} from 'vitest';
import {getReturnToPadUrl} from '../../../static/js/timesliderReturnUrl';

const ts = 'https://pad.example/p/testpad/timeslider?embed=1';

describe('getReturnToPadUrl', () => {
it('follows a same-origin pad referrer', () => {
expect(getReturnToPadUrl('https://pad.example/p/testpad?showChat=false', ts))
.toBe('https://pad.example/p/testpad?showChat=false');
});

it('ignores a cross-origin referrer with a /p/ path (GHSA-35rj-7vrx-29wq)', () => {
for (const ref of ['https://evil.example/p/', 'https://evil.example/p/login',
'https://evil.example/p/deep/x', 'http://pad.example/p/testpad',
'https://pad.example.evil.example/p/testpad']) {
expect(getReturnToPadUrl(ref, ts)).toBe('https://pad.example/p/testpad');
}
});

it('falls back to the pad for an empty or non-pad referrer', () => {
expect(getReturnToPadUrl('', ts)).toBe('https://pad.example/p/testpad');
expect(getReturnToPadUrl('not a url', ts)).toBe('https://pad.example/p/testpad');
expect(getReturnToPadUrl('https://pad.example/admin/', ts))
.toBe('https://pad.example/p/testpad');
});

it('ignores same-origin referrers that are not this pad page', () => {
for (const ref of ['https://pad.example/p/testpad/timeslider?embed=1',
'https://pad.example/p/testpad/export/html', 'https://pad.example/p/otherpad']) {
expect(getReturnToPadUrl(ref, ts)).toBe('https://pad.example/p/testpad');
}
});

it('keeps a reverse-proxy prefix', () => {
expect(getReturnToPadUrl('', 'https://h.example/etherpad/p/a%20b/timeslider?embed=1#x'))
.toBe('https://h.example/etherpad/p/a%20b');
expect(getReturnToPadUrl('https://h.example/etherpad/p/a%20b',
'https://h.example/etherpad/p/a%20b/timeslider?embed=1'))
.toBe('https://h.example/etherpad/p/a%20b');
});
});
Loading