mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-29 21:13:55 +00:00
* 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>
202 lines
8.4 KiB
TypeScript
202 lines
8.4 KiB
TypeScript
'use strict';
|
|
/**
|
|
* Handles the export requests
|
|
*/
|
|
|
|
/*
|
|
* 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
|
|
* 2014 John McLear (Etherpad Foundation / McLear Ltd)
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS-IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
const exporthtml = require('../utils/ExportHtml');
|
|
const exporttxt = require('../utils/ExportTxt');
|
|
const exportEtherpad = require('../utils/ExportEtherpad');
|
|
import crypto from 'node:crypto';
|
|
import fs from 'fs';
|
|
import settings from '../utils/Settings';
|
|
import os from 'os';
|
|
const hooks = require('../../static/js/pluginfw/hooks');
|
|
import util from 'util';
|
|
const { checkValidRev } = require('../utils/checkValidRev');
|
|
|
|
const fsp_writeFile = util.promisify(fs.writeFile);
|
|
const fsp_unlink = util.promisify(fs.unlink);
|
|
|
|
const tempDirectory = os.tmpdir();
|
|
|
|
/**
|
|
* do a requested export
|
|
* @param {Object} req the request object
|
|
* @param {Object} res the response object
|
|
* @param {String} padId the pad id to export
|
|
* @param {String} readOnlyId the read only id of the pad to export
|
|
* @param {String} type the type to export
|
|
*/
|
|
exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string, type:string) => {
|
|
// Validate :rev BEFORE setting Content-Disposition. A bad rev causes
|
|
// checkValidRev to throw, which the route handler catches and renders as a
|
|
// plain-text 500. If we set the attachment header first, the browser would
|
|
// download the error message as a file instead of displaying it.
|
|
if (req.params.rev !== undefined) {
|
|
// modify req, as we use it in a later call to exportConvert
|
|
req.params.rev = checkValidRev(req.params.rev);
|
|
}
|
|
|
|
// avoid naming the read-only file as the original pad's id
|
|
let fileName = readOnlyId ? readOnlyId : padId;
|
|
|
|
// allow fileName to be overwritten by a hook, the type type is kept static for security reasons
|
|
const hookFileName = await hooks.aCallFirst('exportFileName', padId);
|
|
|
|
// if fileName is set then set it to the padId, note that fileName is returned as an array.
|
|
if (hookFileName.length) {
|
|
fileName = hookFileName;
|
|
}
|
|
|
|
// tell the browser that this is a downloadable file
|
|
res.attachment(`${fileName}.${type}`);
|
|
|
|
// if this is a plain text export, we can do this directly
|
|
// We have to over engineer this because tabs are stored as attributes and not plain text
|
|
if (type === 'etherpad') {
|
|
// Honor the :rev URL segment on `.etherpad` exports the same way the
|
|
// other formats already do — revNum limits the serialized pad to revs
|
|
// 0..rev (issue #5071).
|
|
const pad = await exportEtherpad.getPadRaw(padId, readOnlyId, req.params.rev);
|
|
res.send(pad);
|
|
} else if (type === 'txt') {
|
|
const txt = await exporttxt.getPadTXTDocument(padId, req.params.rev);
|
|
res.send(txt);
|
|
} else {
|
|
// render the html document
|
|
let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev, readOnlyId);
|
|
|
|
// decide what to do with the html export
|
|
|
|
// if this is a html export, we can send this from here directly
|
|
if (type === 'html') {
|
|
// do any final changes the plugin might want to make
|
|
const newHTML = await hooks.aCallFirst('exportHTMLSend', html);
|
|
if (newHTML.length) html = newHTML;
|
|
res.send(html);
|
|
return;
|
|
}
|
|
|
|
// Soffice-first dispatch (issue #7538). When soffice is configured
|
|
// we keep the legacy convert-via-tempfile path; when it's not, we
|
|
// hand DOCX to html-to-docx and PDF to our pdfkit walker — both
|
|
// pure-JS, in-process. No fallback chain: native errors surface as
|
|
// 5xx so admins see real failures instead of silent shadowing.
|
|
const {sofficeAvailable} = require('../utils/Settings');
|
|
const sofState = sofficeAvailable();
|
|
const goNative = sofState === 'no'
|
|
|| (sofState === 'withoutPDF' && type === 'pdf');
|
|
|
|
if (goNative) {
|
|
const {
|
|
stripRemoteImages, extractBody, wrapLooseLines, dropEmptyBlocks,
|
|
applyMonospaceToCode,
|
|
} = require('../utils/ExportSanitizeHtml');
|
|
// The HTML pipeline returns a full document (head, style, body); the
|
|
// legacy soffice path renders that fine, but the in-process
|
|
// converters need just the body content to avoid leaking CSS into
|
|
// the output and to drop the document-level whitespace that creates
|
|
// stray paragraph breaks at the top of the result.
|
|
// dropEmptyBlocks strips heading-styled blank-line wrappers that
|
|
// ep_headings2 emits between every styled line.
|
|
const bodyHtml = dropEmptyBlocks(stripRemoteImages(extractBody(html)));
|
|
html = null;
|
|
try {
|
|
if (type === 'docx') {
|
|
// applyMonospaceToCode strips `<code>`/`<pre>`/`<tt>` wrappers
|
|
// (html-to-docx ignores them AND has a bug where it drops
|
|
// `<a href>` children of those tags) and emits styled
|
|
// monospace spans, forwarding any block-level alignment style
|
|
// to a wrapping `<p>`. Run BEFORE wrapLooseLines so the
|
|
// resulting `<p>` lands at the loose-line boundary instead
|
|
// of getting double-wrapped.
|
|
//
|
|
// wrapLooseLines then handles `<br>` semantics: bare `<br>`
|
|
// outside `<p>` becomes a soft break, `<br><br>` becomes a
|
|
// paragraph boundary plus blank-line markers.
|
|
const docxHtml = wrapLooseLines(applyMonospaceToCode(bodyHtml));
|
|
const htmlToDocx = require('html-to-docx');
|
|
const buf = await htmlToDocx(docxHtml);
|
|
res.contentType(
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
res.send(buf);
|
|
return;
|
|
}
|
|
if (type === 'pdf') {
|
|
const {htmlToPdfBuffer} = require('../utils/ExportPdfNative');
|
|
const buf = await htmlToPdfBuffer(bodyHtml);
|
|
res.contentType('application/pdf');
|
|
res.send(buf);
|
|
return;
|
|
}
|
|
// soffice-only formats (odt, doc) are blocked at the route guard
|
|
// when soffice is null; reaching here means the guard is wrong.
|
|
res.status(500).send(`Cannot export ${type} without soffice configured`);
|
|
return;
|
|
} catch (err) {
|
|
console.error(
|
|
`native ${type} export failed for pad "${padId}":`,
|
|
err && (err as Error).stack ? (err as Error).stack : err);
|
|
res.status(500).send(`Failed to export pad as ${type}.`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// soffice path — write the html export to a file. Use CSPRNG output
|
|
// 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`;
|
|
// 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;
|
|
|
|
// send the convert job to the converter (libreoffice)
|
|
const destFile = `${tempDirectory}/etherpad_export_${randNum}.${type}`;
|
|
|
|
// Allow plugins to overwrite the convert in export process
|
|
const result = await hooks.aCallAll('exportConvert', {srcFile, destFile, req, res});
|
|
if (result.length > 0) {
|
|
// console.log("export handled by plugin", destFile);
|
|
} else {
|
|
const converter = require('../utils/LibreOffice');
|
|
await converter.convertFile(srcFile, destFile, type);
|
|
}
|
|
|
|
// send the file
|
|
await res.sendFile(destFile, null);
|
|
|
|
// clean up temporary files
|
|
await fsp_unlink(srcFile);
|
|
|
|
// 100ms delay to accommodate for slow windows fs
|
|
if (os.type().indexOf('Windows') > -1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
|
|
await fsp_unlink(destFile);
|
|
}
|
|
};
|