diff --git a/www/integrations/employee-portal/README.md b/www/integrations/employee-portal/README.md index b446654..611a4e8 100644 --- a/www/integrations/employee-portal/README.md +++ b/www/integrations/employee-portal/README.md @@ -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 before credentials are sent, and failed authenticated downloads show an inline 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 diff --git a/www/src/scripts/employee-portal.ts b/www/src/scripts/employee-portal.ts index 78ab3d3..75e487a 100644 --- a/www/src/scripts/employee-portal.ts +++ b/www/src/scripts/employee-portal.ts @@ -25,7 +25,7 @@ export function safePortalContent(html: string): DocumentFragment { if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank'); const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? ''); 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; } } catch { /* Invalid destination remains visible text without navigation. */ } @@ -126,14 +126,16 @@ function initialize() { const link = document.createElement('a'); link.href = objectUrl; link.download = filename; link.hidden = true; 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) => { const target = event.target instanceof Element ? event.target.closest('a[data-portal-resource-role]') : null; if (!target) return; event.preventDefault(); if (downloading) return; - const resource = protectedPortalResource(target.href, location.origin); + const resource = protectedPortalResource(target.href, location.origin, target.textContent ?? ''); if (!resource || !credential) { downloadFeedback.textContent = portalDownloadErrorMessage(undefined); downloadFeedback.hidden = false; diff --git a/www/src/utils/portal-download.mjs b/www/src/utils/portal-download.mjs index 28dada5..d601faa 100644 --- a/www/src/utils/portal-download.mjs +++ b/www/src/utils/portal-download.mjs @@ -1,5 +1,6 @@ const roles = new Set(['staff', 'management']); 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) { if (id === 'b') return 'staff'; @@ -7,13 +8,32 @@ export function portalRoleForStreamId(id) { 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; try { url = new URL(href, origin); } catch { return undefined; } if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined; const match = resourcePath.exec(url.pathname); 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) { @@ -26,22 +46,6 @@ function basicAuthorization(role, code) { 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 { 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.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) { diff --git a/www/tools/service-portal-browser.mjs b/www/tools/service-portal-browser.mjs index e9066ad..71b95ee 100644 --- a/www/tools/service-portal-browser.mjs +++ b/www/tools/service-portal-browser.mjs @@ -74,11 +74,15 @@ try { const duration = 30 * 86400000; const staffCode = 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 = '

Synthetic Directory

Operational reports

Synthetic report

Fixture directory
RoleContact
Fixture roleFixture email

Second fixture report

Operations Monthly Lineup Report - PM

Unsafe destination
'; const staffTemplates = '

Management Templates

Other Staff Resources

General staff resource

Staff email

'; const payload = { alg: portalAlgorithm, streams: [ - await encryptPortalStream('b', '

Synthetic Staff

Staff fixture only

Download staff guide' + styledFixture + staffTemplates, staffCode), - await encryptPortalStream('a', '

Synthetic Management

Management fixture only

Download management guide' + styledFixture, managementCode), + await encryptPortalStream('b', `

Synthetic Staff

Staff fixture only

Staff's Safety & Training: Résumé (2026)!` + styledFixture + staffTemplates, staffCode), + await encryptPortalStream('a', `

Synthetic Management

Management fixture only

Management – Policies / Q&A #1` + styledFixture, managementCode), ] }; const expectedAuthorization = { staff: 'Basic ' + Buffer.from('staff:' + staffCode, 'utf8').toString('base64'), @@ -89,7 +93,7 @@ try { await context.route('**/*', async (route) => { const req = route.request(), url = new URL(req.url()); 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) { const role = resource[1]; 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' } }); return route.fulfill({ status: 200, - contentType: 'application/pdf', - headers: { 'content-disposition': `attachment; filename="${role}-guide.pdf"`, 'cache-control': 'private, no-store' }, - body: `%PDF-1.7\nSynthetic ${role} document\n`, + contentType: resource[3] === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + headers: { 'cache-control': 'private, no-store' }, + body: resource[3] === 'pdf' ? `%PDF-1.7\nSynthetic ${role} document\n` : `Synthetic ${role} document\n`, }); } 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(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 p.evaluate(async (url) => (await fetch(url)).status, origin + '/staff-resources/staff-guide.pdf'), 401); - const staffLink = staffContent.getByRole('link', { name: 'Download staff guide' }); + assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + staffResourcePath), 401); + 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'); await staffLink.click(); - assert.equal((await staffDownload).suggestedFilename(), 'staff-guide.pdf'); - await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded 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 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'); 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 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'); @@ -405,14 +415,20 @@ try { assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/); await checkPresentation(); - assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + '/management-resources/management-guide.pdf'), 401); - const managementLink = p.getByRole('link', { name: 'Download management guide' }); + assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + managementResourcePath), 401); + 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'); await managementLink.click(); - assert.equal((await managementDownload).suggestedFilename(), 'management-guide.pdf'); - await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded 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-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; - await managementLink.evaluate((link) => { link.href = '/staff-resources/staff-guide.pdf'; }); + await managementLink.evaluate((link, href) => { link.href = href; }, staffResourcePath); await managementLink.click(); 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'); diff --git a/www/tools/service-portal.test.mjs b/www/tools/service-portal.test.mjs index 2b8f44a..8fe0a81 100644 --- a/www/tools/service-portal.test.mjs +++ b/www/tools/service-portal.test.mjs @@ -16,6 +16,7 @@ import { portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource, + recognizableDownloadFilename, } from '../src/utils/portal-download.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('fixture-staff'), undefined); const origin = 'https://www.azinstitute4autism.com'; - const staff = protectedPortalResource('/staff-resources/staff-guide.pdf', origin); - const management = protectedPortalResource('/management-resources/management-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/' + 'b'.repeat(64) + '.docx', origin, 'Management – Policies / Q&A #1'); assert.deepEqual(staff, { - role: 'staff', filename: 'staff-guide.pdf', - url: origin + '/staff-resources/staff-guide.pdf', + role: 'staff', filename: 'a'.repeat(64) + '.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/file.pdf?code=forbidden', 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) => { captured = { url, options }; 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); @@ -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.referrerPolicy, 'no-referrer'); 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'); for (const [resource, credential] of [