fix(portal): preserve readable download filenames

This commit is contained in:
DeCentN2Madness authored and AIAMarketing committed 2026-11-05 19:21:26 -07:00
1 parent 596246f20a
commit 89cdb00765
5 files changed
+76 -45

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ document without opening nginx's native Basic Auth dialog. Staff uses the
stream `a`. Direct URLs remain nginx-protected, cross-role fetches are rejected stream `a`. Direct URLs remain nginx-protected, cross-role fetches are rejected
before credentials are sent, and failed authenticated downloads show an inline before credentials are sent, and failed authenticated downloads show an inline
error without direct-navigation fallback. No code enters a URL, browser error without direct-navigation fallback. No code enters a URL, browser
storage, public artifact, or log. storage, public artifact, or log. Downloads use a bounded safe form of the
approved link label and preserve the validated extension from the protected
SHA-256 server destination; server resources are not renamed.
## Approved production ciphertext ## Approved production ciphertext
+5 -3
View File
@@ -25,7 +25,7 @@ export function safePortalContent(html: string): DocumentFragment {
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank'); if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? ''); const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? '');
if (presentation) element.className = presentation; if (presentation) element.className = presentation;
const resource = protectedPortalResource(url.href, location.origin); const resource = protectedPortalResource(url.href, location.origin, source.textContent ?? '');
if (resource) element.dataset.portalResourceRole = resource.role; if (resource) element.dataset.portalResourceRole = resource.role;
} }
} catch { /* Invalid destination remains visible text without navigation. */ } } catch { /* Invalid destination remains visible text without navigation. */ }
@@ -126,14 +126,16 @@ function initialize() {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = objectUrl; link.download = filename; link.hidden = true; link.href = objectUrl; link.download = filename; link.hidden = true;
document.body.append(link); link.click(); link.remove(); document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 0); // Both supported browsers have ample time to hand the Blob to their
// download manager before its temporary URL is released.
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
}; };
content.addEventListener('click', (event) => { content.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[data-portal-resource-role]') : null; const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[data-portal-resource-role]') : null;
if (!target) return; if (!target) return;
event.preventDefault(); event.preventDefault();
if (downloading) return; if (downloading) return;
const resource = protectedPortalResource(target.href, location.origin); const resource = protectedPortalResource(target.href, location.origin, target.textContent ?? '');
if (!resource || !credential) { if (!resource || !credential) {
downloadFeedback.textContent = portalDownloadErrorMessage(undefined); downloadFeedback.textContent = portalDownloadErrorMessage(undefined);
downloadFeedback.hidden = false; downloadFeedback.hidden = false;
+23 -19
View File
@@ -1,5 +1,6 @@
const roles = new Set(['staff', 'management']); const roles = new Set(['staff', 'management']);
const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/; const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/;
const serverExtension = /([.][a-z0-9]{1,10})$/i;
export function portalRoleForStreamId(id) { export function portalRoleForStreamId(id) {
if (id === 'b') return 'staff'; if (id === 'b') return 'staff';
@@ -7,13 +8,32 @@ export function portalRoleForStreamId(id) {
return undefined; return undefined;
} }
export function protectedPortalResource(href, origin) { export function recognizableDownloadFilename(label, serverFilename) {
const extension = serverExtension.exec(serverFilename)?.[1]?.toLowerCase();
if (!extension) return undefined;
let title = typeof label === 'string' ? label.trim() : '';
if (title.toLowerCase().endsWith(extension)) title = title.slice(0, -extension.length);
const cleaned = title
.normalize('NFKD')
.replace(/\p{M}/gu, '')
.replace(/[’']/g, '')
.replace(/&/g, ' and ')
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');
const base = [...cleaned].slice(0, 180).join('').replace(/-+$/g, '');
if (!base || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(base)) return `AIA-document${extension}`;
return `${base}${extension}`;
}
export function protectedPortalResource(href, origin, label = '') {
let url; let url;
try { url = new URL(href, origin); } catch { return undefined; } try { url = new URL(href, origin); } catch { return undefined; }
if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined; if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined;
const match = resourcePath.exec(url.pathname); const match = resourcePath.exec(url.pathname);
if (!match) return undefined; if (!match) return undefined;
return { role: match[1], filename: match[2], url: url.href }; const downloadFilename = recognizableDownloadFilename(label, match[2]);
if (!downloadFilename) return undefined;
return { role: match[1], filename: match[2], downloadFilename, url: url.href };
} }
function basicAuthorization(role, code) { function basicAuthorization(role, code) {
@@ -26,22 +46,6 @@ function basicAuthorization(role, code) {
return `Basic ${btoa(binary)}`; return `Basic ${btoa(binary)}`;
} }
function safeFilename(value, fallback) {
if (typeof value !== 'string') return fallback;
const name = value.trim();
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(name) ? name : fallback;
}
function responseFilename(response, fallback) {
const disposition = response.headers.get('content-disposition') ?? '';
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
if (encoded) {
try { return safeFilename(decodeURIComponent(encoded), fallback); } catch { /* Use URL filename. */ }
}
const quoted = disposition.match(/filename="([^"]+)"/i)?.[1];
return safeFilename(quoted, fallback);
}
export class PortalDownloadError extends Error { export class PortalDownloadError extends Error {
constructor(code) { super(code); this.name = 'PortalDownloadError'; this.code = code; } constructor(code) { super(code); this.name = 'PortalDownloadError'; this.code = code; }
} }
@@ -58,7 +62,7 @@ export async function fetchProtectedPortalResource(resource, credential, fetcher
}); });
if (response.status === 401 || response.status === 403) throw new PortalDownloadError('unauthorized'); if (response.status === 401 || response.status === 403) throw new PortalDownloadError('unauthorized');
if (!response.ok) throw new PortalDownloadError('unavailable'); if (!response.ok) throw new PortalDownloadError('unavailable');
return { blob: await response.blob(), filename: responseFilename(response, resource.filename) }; return { blob: await response.blob(), filename: resource.downloadFilename };
} }
export function portalDownloadErrorMessage(error) { export function portalDownloadErrorMessage(error) {
+32 -16
View File
@@ -74,11 +74,15 @@ try {
const duration = 30 * 86400000; const duration = 30 * 86400000;
const staffCode = randomBytes(24).toString('hex'); const staffCode = randomBytes(24).toString('hex');
const managementCode = randomBytes(24).toString('hex'); const managementCode = randomBytes(24).toString('hex');
const staffResourceName = 'a'.repeat(64) + '.pdf';
const managementResourceName = 'b'.repeat(64) + '.docx';
const staffResourcePath = `/staff-resources/${staffResourceName}`;
const managementResourcePath = `/management-resources/${managementResourceName}`;
const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><p><a class="unrecognized-report-legacy" href="https://example.invalid/third">Operations Monthly Lineup Report - PM</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>'; const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><p><a class="unrecognized-report-legacy" href="https://example.invalid/third">Operations Monthly Lineup Report - PM</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>';
const staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In &amp; Check-Out</a></div></li><li><div><a href="https://example.invalid/employee-performance">Employee Performance Template</a></div></li><li><div><a href="https://example.invalid/employee-availability">Employee Availability Template</a></div></li></ul></section><section><h2>Other Staff Resources</h2><p><a href="https://example.invalid/resource">General staff resource</a></p><p><a href="mailto:staff@example.invalid">Staff email</a></p></section>'; const staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In &amp; Check-Out</a></div></li><li><div><a href="https://example.invalid/employee-performance">Employee Performance Template</a></div></li><li><div><a href="https://example.invalid/employee-availability">Employee Availability Template</a></div></li></ul></section><section><h2>Other Staff Resources</h2><p><a href="https://example.invalid/resource">General staff resource</a></p><p><a href="mailto:staff@example.invalid">Staff email</a></p></section>';
const payload = { alg: portalAlgorithm, streams: [ const payload = { alg: portalAlgorithm, streams: [
await encryptPortalStream('b', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="/staff-resources/staff-guide.pdf">Download staff guide</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">' + styledFixture + staffTemplates, staffCode), await encryptPortalStream('b', `<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="${staffResourcePath}">Staff's Safety &amp; Training: Résumé (2026)!</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">` + styledFixture + staffTemplates, staffCode),
await encryptPortalStream('a', '<h2>Synthetic Management</h2><p>Management fixture only</p><a class="hs-button" href="/management-resources/management-guide.pdf">Download management guide</a>' + styledFixture, managementCode), await encryptPortalStream('a', `<h2>Synthetic Management</h2><p>Management fixture only</p><a class="hs-button" href="${managementResourcePath}">Management – Policies / Q&amp;A #1</a>` + styledFixture, managementCode),
] }; ] };
const expectedAuthorization = { const expectedAuthorization = {
staff: 'Basic ' + Buffer.from('staff:' + staffCode, 'utf8').toString('base64'), staff: 'Basic ' + Buffer.from('staff:' + staffCode, 'utf8').toString('base64'),
@@ -89,7 +93,7 @@ try {
await context.route('**/*', async (route) => { await context.route('**/*', async (route) => {
const req = route.request(), url = new URL(req.url()); const req = route.request(), url = new URL(req.url());
if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) { if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) {
const resource = /^\/(staff|management)-resources\/(staff|management)-guide\.pdf$/.exec(url.pathname); const resource = /^\/(staff|management)-resources\/([a-f0-9]{64})[.](pdf|docx)$/.exec(url.pathname);
if (resource) { if (resource) {
const role = resource[1]; const role = resource[1];
const authorized = req.headers().authorization === expectedAuthorization[role]; const authorized = req.headers().authorization === expectedAuthorization[role];
@@ -97,9 +101,9 @@ try {
if (!authorized) return route.fulfill({ status: 401, headers: { 'www-authenticate': `Basic realm="AIA ${role} resources"`, 'cache-control': 'private, no-store' } }); if (!authorized) return route.fulfill({ status: 401, headers: { 'www-authenticate': `Basic realm="AIA ${role} resources"`, 'cache-control': 'private, no-store' } });
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: 'application/pdf', contentType: resource[3] === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
headers: { 'content-disposition': `attachment; filename="${role}-guide.pdf"`, 'cache-control': 'private, no-store' }, headers: { 'cache-control': 'private, no-store' },
body: `%PDF-1.7\nSynthetic ${role} document\n`, body: resource[3] === 'pdf' ? `%PDF-1.7\nSynthetic ${role} document\n` : `Synthetic ${role} document\n`,
}); });
} }
if (inject && url.pathname === '/employee-portal') { if (inject && url.pathname === '/employee-portal') {
@@ -382,15 +386,21 @@ try {
assert.equal(columns, width >= 768 ? 2 : 1, 'staff templates follow responsive column count'); assert.equal(columns, width >= 768 ? 2 : 1, 'staff templates follow responsive column count');
assert.equal(await staffContent.getByRole('link', { name: 'General staff resource' }).getAttribute('class'), 'portal-resource-button'); assert.equal(await staffContent.getByRole('link', { name: 'General staff resource' }).getAttribute('class'), 'portal-resource-button');
assert.equal(await staffContent.getByRole('link', { name: 'Staff email' }).getAttribute('class'), 'portal-contact-link'); assert.equal(await staffContent.getByRole('link', { name: 'Staff email' }).getAttribute('class'), 'portal-contact-link');
assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + '/staff-resources/staff-guide.pdf'), 401); assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + staffResourcePath), 401);
const staffLink = staffContent.getByRole('link', { name: 'Download staff guide' }); await p.evaluate(() => {
window.portalObjectUrlRevocations = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => { window.portalObjectUrlRevocations.push(url); revoke(url); };
});
const staffLink = staffContent.getByRole('link', { name: "Staff's Safety & Training: Résumé (2026)!" });
const staffDownload = p.waitForEvent('download'); const staffDownload = p.waitForEvent('download');
await staffLink.click(); await staffLink.click();
assert.equal((await staffDownload).suggestedFilename(), 'staff-guide.pdf'); assert.equal((await staffDownload).suggestedFilename(), 'Staffs-Safety-and-Training-Resume-2026.pdf');
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded staff-guide.pdf.'); await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded Staffs-Safety-and-Training-Resume-2026.pdf.');
assert.equal(await p.evaluate(() => window.portalObjectUrlRevocations.length), 0, 'Blob URL remains valid while the browser begins the download');
assert.equal(p.url(), origin + '/employee-portal'); assert.equal(p.url(), origin + '/employee-portal');
const beforeCrossRole = evidence.resourceRequests.length; const beforeCrossRole = evidence.resourceRequests.length;
await staffLink.evaluate((link) => { link.href = '/management-resources/management-guide.pdf'; }); await staffLink.evaluate((link, href) => { link.href = href; }, managementResourcePath);
await staffLink.click(); await staffLink.click();
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal')); await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal'));
assert.equal(evidence.resourceRequests.length, beforeCrossRole, 'staff credential is not sent to management resources'); assert.equal(evidence.resourceRequests.length, beforeCrossRole, 'staff credential is not sent to management resources');
@@ -405,14 +415,20 @@ try {
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/); assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
await checkPresentation(); await checkPresentation();
assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + '/management-resources/management-guide.pdf'), 401); assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + managementResourcePath), 401);
const managementLink = p.getByRole('link', { name: 'Download management guide' }); await p.evaluate(() => {
window.portalObjectUrlRevocations = [];
const revoke = URL.revokeObjectURL.bind(URL);
URL.revokeObjectURL = (url) => { window.portalObjectUrlRevocations.push(url); revoke(url); };
});
const managementLink = p.getByRole('link', { name: 'Management – Policies / Q&A #1' });
const managementDownload = p.waitForEvent('download'); const managementDownload = p.waitForEvent('download');
await managementLink.click(); await managementLink.click();
assert.equal((await managementDownload).suggestedFilename(), 'management-guide.pdf'); assert.equal((await managementDownload).suggestedFilename(), 'Management-Policies-Q-and-A-1.docx');
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded management-guide.pdf.'); await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded Management-Policies-Q-and-A-1.docx.');
assert.equal(await p.evaluate(() => window.portalObjectUrlRevocations.length), 0, 'Blob URL remains valid while the browser begins the download');
const beforeManagementCrossRole = evidence.resourceRequests.length; const beforeManagementCrossRole = evidence.resourceRequests.length;
await managementLink.evaluate((link) => { link.href = '/staff-resources/staff-guide.pdf'; }); await managementLink.evaluate((link, href) => { link.href = href; }, staffResourcePath);
await managementLink.click(); await managementLink.click();
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal')); await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal'));
assert.equal(evidence.resourceRequests.length, beforeManagementCrossRole, 'management credential is not sent to staff resources'); assert.equal(evidence.resourceRequests.length, beforeManagementCrossRole, 'management credential is not sent to staff resources');
+13 -6
View File
@@ -16,6 +16,7 @@ import {
portalDownloadErrorMessage, portalDownloadErrorMessage,
portalRoleForStreamId, portalRoleForStreamId,
protectedPortalResource, protectedPortalResource,
recognizableDownloadFilename,
} from '../src/utils/portal-download.mjs'; } from '../src/utils/portal-download.mjs';
import { applyRoleMappings, approvedMapping, summarizeMappings } from './portal-production.mjs'; import { applyRoleMappings, approvedMapping, summarizeMappings } from './portal-production.mjs';
@@ -60,12 +61,17 @@ test('portal downloads use only the validated role and an in-memory authorizatio
assert.equal(portalRoleForStreamId('a'), 'management'); assert.equal(portalRoleForStreamId('a'), 'management');
assert.equal(portalRoleForStreamId('fixture-staff'), undefined); assert.equal(portalRoleForStreamId('fixture-staff'), undefined);
const origin = 'https://www.azinstitute4autism.com'; const origin = 'https://www.azinstitute4autism.com';
const staff = protectedPortalResource('/staff-resources/staff-guide.pdf', origin); const staff = protectedPortalResource('/staff-resources/' + 'a'.repeat(64) + '.pdf', origin, "Staff's Safety & Training: Résumé (2026)!");
const management = protectedPortalResource('/management-resources/management-guide.pdf', origin); const management = protectedPortalResource('/management-resources/' + 'b'.repeat(64) + '.docx', origin, 'Management – Policies / Q&A #1');
assert.deepEqual(staff, { assert.deepEqual(staff, {
role: 'staff', filename: 'staff-guide.pdf', role: 'staff', filename: 'a'.repeat(64) + '.pdf',
url: origin + '/staff-resources/staff-guide.pdf', downloadFilename: 'Staffs-Safety-and-Training-Resume-2026.pdf',
url: origin + '/staff-resources/' + 'a'.repeat(64) + '.pdf',
}); });
assert.equal(management.downloadFilename, 'Management-Policies-Q-and-A-1.docx');
assert.equal(recognizableDownloadFilename('Client Check-In & Check-Out.xlsx', 'c'.repeat(64) + '.xlsx'), 'Client-Check-In-and-Check-Out.xlsx');
assert.equal(recognizableDownloadFilename('../../', 'd'.repeat(64) + '.pdf'), 'AIA-document.pdf');
assert.equal(recognizableDownloadFilename('No server extension', 'e'.repeat(64)), undefined);
assert.equal(protectedPortalResource('/staff-resources/nested/file.pdf', origin), undefined); assert.equal(protectedPortalResource('/staff-resources/nested/file.pdf', origin), undefined);
assert.equal(protectedPortalResource('/staff-resources/file.pdf?code=forbidden', origin), undefined); assert.equal(protectedPortalResource('/staff-resources/file.pdf?code=forbidden', origin), undefined);
assert.equal(protectedPortalResource('https://other.invalid/staff-resources/file.pdf', origin), undefined); assert.equal(protectedPortalResource('https://other.invalid/staff-resources/file.pdf', origin), undefined);
@@ -75,7 +81,8 @@ test('portal downloads use only the validated role and an in-memory authorizatio
const result = await fetchProtectedPortalResource(staff, { role: 'staff', code }, async (url, options) => { const result = await fetchProtectedPortalResource(staff, { role: 'staff', code }, async (url, options) => {
captured = { url, options }; captured = { url, options };
return new Response('synthetic document', { return new Response('synthetic document', {
headers: { 'content-disposition': 'attachment; filename="approved-name.pdf"' }, // nginx may expose the hash name; the approved label remains authoritative.
headers: { 'content-disposition': `attachment; filename="${staff.filename}"` },
}); });
}); });
assert.equal(captured.url, staff.url); assert.equal(captured.url, staff.url);
@@ -86,7 +93,7 @@ test('portal downloads use only the validated role and an in-memory authorizatio
assert.equal(captured.options.redirect, 'error'); assert.equal(captured.options.redirect, 'error');
assert.equal(captured.options.referrerPolicy, 'no-referrer'); assert.equal(captured.options.referrerPolicy, 'no-referrer');
assert.match(captured.options.headers.Authorization, /^Basic /); assert.match(captured.options.headers.Authorization, /^Basic /);
assert.equal(result.filename, 'approved-name.pdf'); assert.equal(result.filename, 'Staffs-Safety-and-Training-Resume-2026.pdf');
assert.equal(await result.blob.text(), 'synthetic document'); assert.equal(await result.blob.text(), 'synthetic document');
for (const [resource, credential] of [ for (const [resource, credential] of [