security(export): strip remote images on the soffice path to match the native path (#8071)

* security(export): strip remote images on the soffice path to match the native path

Defense-in-depth / consistency fix — not a core vulnerability on its own.
Core Etherpad never emits <img> tags in export HTML; they only appear
when a plugin/hook injects them, so sanitising such content is primarily
the injecting plugin's responsibility.

However, the native in-process export path (issue #7538) already calls
stripRemoteImages() defensively, while the LibreOffice (soffice) path
wrote the HTML to the temp file verbatim. soffice is the only export
path that actually performs outbound fetches for remote <img> URLs during
conversion, so a plugin-injected remote image there becomes a blind SSRF
sink. This makes the soffice path consistent with the native path core
already ships.

Adds a regression test that injects a remote image via
exportHTMLAdditionalContent, intercepts the temp file via the
exportConvert hook, and asserts no remote image URL survives.
Remote-image behaviour reported privately by meifukun.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* export: preserve doctype and comments in stripRemoteImages

Addresses Qodo review on #8071. Applying stripRemoteImages() to the full
export document for the soffice path dropped `<!doctype html>` and HTML
comments, because the htmlparser2 handler only emitted open/text/close
tags. A missing doctype can flip LibreOffice's HTML import into quirks
mode, subtly changing rendering for all soffice exports.

Add onprocessinginstruction (doctype) and oncomment handlers so the
serializer round-trips document directives and comments while still
stripping remote images. The native path is unaffected (it strips only
extractBody() output, which has no doctype). Adds regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-07-27 19:18:47 +01:00 committed by GitHub
parent f3ccc6c718
commit 492b158d93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 146 additions and 1 deletions

View file

@ -163,7 +163,13 @@ exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string,
// for the temp path token (see matching note in ImportHandler.ts).
const randNum = crypto.randomBytes(16).toString('hex');
const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;
await fsp_writeFile(srcFile, html);
// Strip remote <img> tags before handing the document to LibreOffice.
// soffice fetches remote image URLs during conversion, so any plugin/hook
// that injects an <img src="http://..."> into export HTML would otherwise
// turn export into a blind SSRF sink. The native path already does this
// (see stripRemoteImages above); apply it here so both paths match.
const {stripRemoteImages} = require('../utils/ExportSanitizeHtml');
await fsp_writeFile(srcFile, stripRemoteImages(html));
// ensure html can be collected by the garbage collector
html = null;

View file

@ -208,6 +208,17 @@ export const stripRemoteImages = (html: string): string => {
if (VOID_TAGS.has(name)) return;
out += `</${name}>`;
},
// Preserve document-level directives (notably `<!doctype html>`) and
// comments. stripRemoteImages() runs on the FULL export document for the
// soffice path, so dropping the doctype would flip LibreOffice into quirks
// mode. htmlparser2 surfaces the doctype as a processing instruction whose
// `data` is e.g. `!doctype html`.
onprocessinginstruction(name, data) {
out += `<${data}>`;
},
oncomment(data) {
out += `<!--${data}-->`;
},
}, {decodeEntities: false, lowerCaseTags: true});
parser.write(html);
parser.end();