From ededaee16cc3d70f414735ad865d7ab0dfb66518 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:05:46 +0900 Subject: [PATCH 1/9] fix(import): accept XML delimiter whitespace --- CHANGELOG.md | 4 ++ cloud-sync.js | 55 ++++++++++------ .../ms-project-xml-import-boundary.md | 63 +++++++++++++++++++ docs/security.md | 2 +- tests/unit/msproject.test.mjs | 43 +++++++++++++ 5 files changed, 148 insertions(+), 19 deletions(-) create mode 100644 docs/doctoring/ms-project-xml-import-boundary.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 787ee51b..0c11c23d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Accepted XML whitespace before exact Microsoft Project element delimiters + while preserving the linear, regex-free import scanner and rejecting + attributes, longer names, non-XML whitespace, nested unmatched blocks, and + truncated input. - Attachment-list status refresh now removes the per-row database lookup, uses a configurable bounded worker pool with per-item abortable timeouts and a request-wide latency budget, preserves stale status after downstream, diff --git a/cloud-sync.js b/cloud-sync.js index 9016cfbf..68e36b03 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -741,33 +741,52 @@ function openReportModal() { export function parseMsProjectXml(xml) { // Fully linear extract (indexOf/slice) — no dynamic RegExp and no lazy // [\s\S]*? block collectors (those can quadratic-backtrack on truncated input). + const isXmlWhitespace = (charCode) => ( + charCode === 0x20 || charCode === 0x09 || charCode === 0x0d || charCode === 0x0a + ); + const findTagBoundary = (source, name, from, closing = false) => { + const prefix = `<${closing ? '/' : ''}${name}`; + let searchFrom = from; + for (;;) { + const start = source.indexOf(prefix, searchFrom); + if (start === -1) return null; + let delimiter = start + prefix.length; + while (delimiter < source.length && isXmlWhitespace(source.charCodeAt(delimiter))) { + delimiter += 1; + } + if (source.charCodeAt(delimiter) === 0x3e) { + return { start, end: delimiter + 1 }; + } + // Reject attributes, longer names, and non-XML whitespace while advancing + // past every inspected byte so malformed candidates are never rescanned. + searchFrom = Math.max(delimiter + 1, start + prefix.length); + } + }; const tag = (block, name) => { - const openingTag = `<${name}>`; - const closingTag = ``; - const valueStart = block.indexOf(openingTag); - if (valueStart === -1) return ''; - const contentStart = valueStart + openingTag.length; - const valueEnd = block.indexOf(closingTag, contentStart); - return valueEnd === -1 ? '' : block.slice(contentStart, valueEnd).trim(); + const opening = findTagBoundary(block, name, 0); + if (!opening) return ''; + const closing = findTagBoundary(block, name, opening.end, true); + return closing ? block.slice(opening.end, closing.start).trim() : ''; }; - const collectBlocks = (source, openTag, closeTag) => { + const collectBlocks = (source, name) => { const out = []; let from = 0; for (;;) { - const start = source.indexOf(openTag, from); - if (start === -1) break; - const contentStart = start + openTag.length; - const end = source.indexOf(closeTag, contentStart); - // Incomplete open tag: stop linearly (do not rescan the remainder). - if (end === -1) break; - out.push(source.slice(start, end + closeTag.length)); - from = end + closeTag.length; + const opening = findTagBoundary(source, name, from); + if (!opening) break; + const closing = findTagBoundary(source, name, opening.end, true); + const nextOpening = findTagBoundary(source, name, opening.end); + // Incomplete or nested same-name block: stop at the first unmatched + // opening tag instead of pairing it with a later block's closing tag. + if (!closing || (nextOpening && nextOpening.start < closing.start)) break; + out.push(source.slice(opening.start, closing.end)); + from = closing.end; } return out; }; const predecessorIds = (block) => { const ids = []; - for (const link of collectBlocks(block, '', '')) { + for (const link of collectBlocks(block, 'PredecessorLink')) { const uid = tag(link, 'PredecessorUID'); if (/^\d+$/.test(uid)) ids.push(`msp-${uid}`); } @@ -779,7 +798,7 @@ export function parseMsProjectXml(xml) { const day = (s) => (/^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : ''); const tasks = []; const parents = {}; // depth -> last task id at that depth - const blocks = collectBlocks(String(xml || ''), '', ''); + const blocks = collectBlocks(String(xml || ''), 'Task'); for (const block of blocks) { const uid = tag(block, 'UID'); const name = unescape(tag(block, 'Name')); diff --git a/docs/doctoring/ms-project-xml-import-boundary.md b/docs/doctoring/ms-project-xml-import-boundary.md new file mode 100644 index 00000000..0a8fa5aa --- /dev/null +++ b/docs/doctoring/ms-project-xml-import-boundary.md @@ -0,0 +1,63 @@ +# Microsoft Project XML delimiter boundary + +## Decision + +ScopeWeave's Microsoft Project import profile accepts XML whitespace between an +exact supported element name and the closing `>` delimiter. The accepted code +points are: + +- U+0020 SPACE; +- U+0009 CHARACTER TABULATION; +- U+000D CARRIAGE RETURN; and +- U+000A LINE FEED. + +The parser deliberately does not become a general XML processor. It recognizes +only the exact `Task`, `PredecessorLink`, and scalar element names already used +by the import adapter. Attributes, namespace prefixes, longer lookalike names, +non-XML whitespace, self-closing forms, nested same-name blocks, and truncated +blocks are rejected or yield no value under this narrow profile. + +## Security and complexity boundary + +The scanner remains monotonic and regex-free. It advances through every rejected +candidate and uses bounded `indexOf()` and `slice()` operations rather than +constructing dynamic regular expressions or lazy whole-document block matches. +This preserves the existing denial-of-service boundary for malformed or +adversarial uploads. + +An unmatched outer element cannot consume a later nested element's closing tag. +If another same-name opening appears before the candidate closing tag, block +collection stops at the unmatched outer element instead of silently producing a +mis-parented task. + +## Executable evidence + +`tests/unit/msproject.test.mjs` covers: + +- space, tab, carriage-return, and line-feed delimiters; +- scalar and predecessor-link elements using each allowed delimiter; +- an actual U+000B vertical tab, which is not XML whitespace; +- attributes and longer element names; +- truncated and repeated unclosed task blocks; +- nested same-name openings before a closing element; and +- the existing valid import and predecessor contracts. + +The test is already part of the full unit and coverage command paths. No package +or lockfile change is required. + +## Compatibility and rollback + +The change broadens acceptance only for documents that are conformant with the +XML whitespace production at the delimiter positions used by this adapter. +Existing byte-exact exports retain the same task identifiers, names, dates, +parents, progress, and predecessor values. + +Rollback must revert the scanner, focused tests, security documentation, +CHANGELOG entry, and this record together. Reintroducing byte-exact delimiters +would again reject standards-compliant Microsoft Project exports that contain +formatting whitespace before `>`. + +## Reference + +World Wide Web Consortium. (2008). *Extensible Markup Language (XML) 1.0 +(Fifth Edition)*. https://www.w3.org/TR/2008/REC-xml-20081126/ diff --git a/docs/security.md b/docs/security.md index 5b21a5e6..0ceee972 100644 --- a/docs/security.md +++ b/docs/security.md @@ -19,7 +19,7 @@ Every user-controlled CSV cell is neutralized when, after optional leading white ## XML imports -Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking. +Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Opening and closing `Task`, `PredecessorLink`, and scalar tags accept only XML whitespace (space, tab, carriage return, or line feed) between the exact element name and `>`. Attributes, longer names, and other whitespace code points are not accepted by this deliberately narrow import profile. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking. ## Release verification diff --git a/tests/unit/msproject.test.mjs b/tests/unit/msproject.test.mjs index d829a32c..b97727ff 100644 --- a/tests/unit/msproject.test.mjs +++ b/tests/unit/msproject.test.mjs @@ -72,4 +72,47 @@ assert.deepEqual( const incompleteOpens = `${'9open'.repeat(5000)}`; assert.deepEqual(parseMsProjectXml(incompleteOpens), [], 'unclosed Task blocks yield no tasks'); +assert.deepEqual( + parseMsProjectXml( + '11unclosed outer12nested', + ), + [], + 'an unmatched outer Task cannot consume a nested Task closing tag', +); + + +const whitespaceTags = parseMsProjectXml(` + + 8 + Whitespace-compatible task + 1 + 2026-08-11T09:00:00 + 2026-08-12T17:00:00 + + 2 + +`); +assert.equal(whitespaceTags.length, 1, 'XML whitespace before tag delimiters is accepted'); +assert.equal(whitespaceTags[0].id, 'msp-8'); +assert.equal(whitespaceTags[0].phase, 'Whitespace-compatible task'); +assert.equal(whitespaceTags[0].plannedStartDate, '2026-08-11'); +assert.equal(whitespaceTags[0].plannedEndDate, '2026-08-12'); +assert.equal(whitespaceTags[0].predecessors, 'msp-2', 'block and scalar tags share the scanner'); + +assert.deepEqual( + parseMsProjectXml('9wrong'), + [], + 'TaskX must not match Task', +); +assert.deepEqual( + parseMsProjectXml('9wrong whitespace'), + [], + 'non-XML whitespace before a delimiter is rejected', +); +assert.deepEqual( + parseMsProjectXml('10truncated'), + [], + 'truncated whitespace-delimited Task stops safely', +); + console.log('✓ MS Project import tests passed'); From 3491f51959a97a4f7bfcc32394b07b130ed6c395 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:36:13 +0900 Subject: [PATCH 2/9] test(import): reject nested scalar elements --- tests/unit/msproject.test.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit/msproject.test.mjs b/tests/unit/msproject.test.mjs index b97727ff..284cb51d 100644 --- a/tests/unit/msproject.test.mjs +++ b/tests/unit/msproject.test.mjs @@ -80,7 +80,6 @@ assert.deepEqual( 'an unmatched outer Task cannot consume a nested Task closing tag', ); - const whitespaceTags = parseMsProjectXml(` 8 @@ -114,5 +113,12 @@ assert.deepEqual( [], 'truncated whitespace-delimited Task stops safely', ); +assert.deepEqual( + parseMsProjectXml( + '13outerinner1', + ), + [], + 'a nested scalar opening cannot consume the inner closing delimiter', +); console.log('✓ MS Project import tests passed'); From dba36c98606e9b78e3ea8c9733f72471dc1726f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 17:49:48 +0900 Subject: [PATCH 3/9] fix(import): reject nested scalar elements --- cloud-sync.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cloud-sync.js b/cloud-sync.js index 68e36b03..0e015ebe 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -766,7 +766,9 @@ export function parseMsProjectXml(xml) { const opening = findTagBoundary(block, name, 0); if (!opening) return ''; const closing = findTagBoundary(block, name, opening.end, true); - return closing ? block.slice(opening.end, closing.start).trim() : ''; + const nextOpening = findTagBoundary(block, name, opening.end); + if (!closing || (nextOpening && nextOpening.start < closing.start)) return ''; + return block.slice(opening.end, closing.start).trim(); }; const collectBlocks = (source, name) => { const out = []; From 26e1c53e8904629d58a77346a10d20ce4115bc94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:11:17 +0900 Subject: [PATCH 4/9] test(security): reproduce stale share project authority --- tests/e2e/cloud.spec.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index fa18cc2e..067959a9 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -135,6 +135,23 @@ test('share link: anonymous visitor gets a read-only view; revoke kills it', asy await anon.close(); }); +test('share link drops stale authenticated project authority', async ({ page }) => { + await loginAndOpen(page); + const sharedProject = await api('/api/projects', { + method: 'POST', + body: { name: '공유 전용 프로젝트' }, + tok: token, + }); + const share = await api(`/api/projects/${sharedProject.id}/shares`, { method: 'POST', tok: token }); + + expect(await page.evaluate(() => localStorage.getItem('scopeweave:project'))).toBe('1'); + await page.goto(`${BASE}/?share=${share.token}`); + await page.waitForSelector('#cloud-auth .team-role-tag'); + + expect(await page.evaluate(() => localStorage.getItem('scopeweave:project'))).toBeNull(); + expect(await page.locator('#cloud-auth button').count()).toBe(0); +}); + test('MSP import: XML file populates the tree and saves to the cloud', async ({ page }) => { await loginAndOpen(page); page.on('dialog', (d) => d.accept()); @@ -159,4 +176,4 @@ test('archive: project moves under the 보관됨 optgroup and restores', async ( expect(archived.some((t) => t.includes('E2E 프로젝트'))).toBeTruthy(); await page.click('#cloud-auth button:has-text("보관 해제")'); await page.waitForFunction(() => !document.querySelector('#cloud-auth select optgroup[label="보관됨"]')); -}); +}); \ No newline at end of file From e8182369c5078587b726322ee1a8a3e8e910f29d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:07:08 +0900 Subject: [PATCH 5/9] test(e2e): keep XML parser PR scope isolated --- tests/e2e/cloud.spec.js | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/tests/e2e/cloud.spec.js b/tests/e2e/cloud.spec.js index 067959a9..fa18cc2e 100644 --- a/tests/e2e/cloud.spec.js +++ b/tests/e2e/cloud.spec.js @@ -135,23 +135,6 @@ test('share link: anonymous visitor gets a read-only view; revoke kills it', asy await anon.close(); }); -test('share link drops stale authenticated project authority', async ({ page }) => { - await loginAndOpen(page); - const sharedProject = await api('/api/projects', { - method: 'POST', - body: { name: '공유 전용 프로젝트' }, - tok: token, - }); - const share = await api(`/api/projects/${sharedProject.id}/shares`, { method: 'POST', tok: token }); - - expect(await page.evaluate(() => localStorage.getItem('scopeweave:project'))).toBe('1'); - await page.goto(`${BASE}/?share=${share.token}`); - await page.waitForSelector('#cloud-auth .team-role-tag'); - - expect(await page.evaluate(() => localStorage.getItem('scopeweave:project'))).toBeNull(); - expect(await page.locator('#cloud-auth button').count()).toBe(0); -}); - test('MSP import: XML file populates the tree and saves to the cloud', async ({ page }) => { await loginAndOpen(page); page.on('dialog', (d) => d.accept()); @@ -176,4 +159,4 @@ test('archive: project moves under the 보관됨 optgroup and restores', async ( expect(archived.some((t) => t.includes('E2E 프로젝트'))).toBeTruthy(); await page.click('#cloud-auth button:has-text("보관 해제")'); await page.waitForFunction(() => !document.querySelector('#cloud-auth select optgroup[label="보관됨"]')); -}); \ No newline at end of file +}); From 5e4922b320ffa5f9ff8ce2bb79228203b2b925a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 00:33:17 +0900 Subject: [PATCH 6/9] test(security): reject JWTs in attachment-view URLs --- tests/api/attachment-view-auth.test.mjs | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/api/attachment-view-auth.test.mjs diff --git a/tests/api/attachment-view-auth.test.mjs b/tests/api/attachment-view-auth.test.mjs new file mode 100644 index 00000000..97f38291 --- /dev/null +++ b/tests/api/attachment-view-auth.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); + +const jsonRequest = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); + +const signup = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ email: 'viewer-auth@example.com', password: 'password123', name: 'Attachment Viewer' }), +}); +assert.equal(signup.status, 200, 'fixture user signs up'); +const { token } = await signup.json(); +const auth = { authorization: `Bearer ${token}` }; + +const projectResponse = await jsonRequest('/api/projects', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'Attachment auth fixture' }), +}); +assert.equal(projectResponse.status, 200, 'fixture project is created'); +const project = await projectResponse.json(); + +const upload = new FormData(); +upload.append('file', new Blob(['%PDF-auth-boundary'], { type: 'application/pdf' }), 'auth-boundary.pdf'); +upload.append('taskId', 'security-task'); +const uploadResponse = await app.request(`/api/projects/${project.id}/attachments`, { + method: 'POST', + headers: auth, + body: upload, +}); +assert.equal(uploadResponse.status, 200, 'fixture attachment uploads'); +const attachment = await uploadResponse.json(); + +const leakedQueryCredential = await jsonRequest( + `/api/projects/${project.id}/attachments/${attachment.id}/view?token=${encodeURIComponent(token)}`, +); +assert.equal(leakedQueryCredential.status, 401, 'general session JWT is never accepted from an attachment-view URL'); + +const viewResponse = await jsonRequest( + `/api/projects/${project.id}/attachments/${attachment.id}/view`, + { headers: auth }, +); +assert.equal(viewResponse.status, 200, 'attachment view link is issued through Authorization-header authentication'); +assert.match(viewResponse.headers.get('content-type') || '', /^application\/json\b/, 'view endpoint returns JSON, not a credential-bearing redirect'); +const viewPayload = await viewResponse.json(); +assert.equal(typeof viewPayload.url, 'string', 'view response returns an artifact URL'); +assert.ok(viewPayload.url.length > 0, 'artifact URL is non-empty'); +assert.equal(viewPayload.url.includes(token), false, 'artifact URL never embeds the general session JWT'); + +const unauthenticated = await jsonRequest(`/api/projects/${project.id}/attachments/${attachment.id}/view`); +assert.equal(unauthenticated.status, 401, 'view endpoint rejects missing Authorization'); + +const secondSignup = await jsonRequest('/api/auth/signup', { + method: 'POST', + body: JSON.stringify({ email: 'other-viewer@example.com', password: 'password123', name: 'Other Viewer' }), +}); +const otherToken = (await secondSignup.json()).token; +const crossTenant = await jsonRequest( + `/api/projects/${project.id}/attachments/${attachment.id}/view`, + { headers: { authorization: `Bearer ${otherToken}` } }, +); +assert.equal(crossTenant.status, 404, 'cross-tenant attachment view remains nondisclosing'); + +console.log('attachment view authentication boundary tests passed'); From 91651c8a9b65d89e6615a02ecaad724dfd510454 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 00:33:41 +0900 Subject: [PATCH 7/9] test(security): execute attachment-view auth regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 46d07bfb..d513d0ef 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/attachment-view-auth.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", From a14a3bd0426e3bedbf5ba472d34f00b7a1c6ac7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 00:36:06 +0900 Subject: [PATCH 8/9] chore(security): keep XML PR scoped to parser fix --- tests/api/attachment-view-auth.test.mjs | 71 ------------------------- 1 file changed, 71 deletions(-) delete mode 100644 tests/api/attachment-view-auth.test.mjs diff --git a/tests/api/attachment-view-auth.test.mjs b/tests/api/attachment-view-auth.test.mjs deleted file mode 100644 index 97f38291..00000000 --- a/tests/api/attachment-view-auth.test.mjs +++ /dev/null @@ -1,71 +0,0 @@ -import assert from 'node:assert/strict'; - -process.env.SCOPEWEAVE_DB = ':memory:'; -process.env.SCOPEWEAVE_DEV = '1'; -process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; - -const { app } = await import('../../server/app.mjs'); - -const jsonRequest = (path, options = {}) => app.request(path, { - ...options, - headers: { 'content-type': 'application/json', ...(options.headers || {}) }, -}); - -const signup = await jsonRequest('/api/auth/signup', { - method: 'POST', - body: JSON.stringify({ email: 'viewer-auth@example.com', password: 'password123', name: 'Attachment Viewer' }), -}); -assert.equal(signup.status, 200, 'fixture user signs up'); -const { token } = await signup.json(); -const auth = { authorization: `Bearer ${token}` }; - -const projectResponse = await jsonRequest('/api/projects', { - method: 'POST', - headers: auth, - body: JSON.stringify({ name: 'Attachment auth fixture' }), -}); -assert.equal(projectResponse.status, 200, 'fixture project is created'); -const project = await projectResponse.json(); - -const upload = new FormData(); -upload.append('file', new Blob(['%PDF-auth-boundary'], { type: 'application/pdf' }), 'auth-boundary.pdf'); -upload.append('taskId', 'security-task'); -const uploadResponse = await app.request(`/api/projects/${project.id}/attachments`, { - method: 'POST', - headers: auth, - body: upload, -}); -assert.equal(uploadResponse.status, 200, 'fixture attachment uploads'); -const attachment = await uploadResponse.json(); - -const leakedQueryCredential = await jsonRequest( - `/api/projects/${project.id}/attachments/${attachment.id}/view?token=${encodeURIComponent(token)}`, -); -assert.equal(leakedQueryCredential.status, 401, 'general session JWT is never accepted from an attachment-view URL'); - -const viewResponse = await jsonRequest( - `/api/projects/${project.id}/attachments/${attachment.id}/view`, - { headers: auth }, -); -assert.equal(viewResponse.status, 200, 'attachment view link is issued through Authorization-header authentication'); -assert.match(viewResponse.headers.get('content-type') || '', /^application\/json\b/, 'view endpoint returns JSON, not a credential-bearing redirect'); -const viewPayload = await viewResponse.json(); -assert.equal(typeof viewPayload.url, 'string', 'view response returns an artifact URL'); -assert.ok(viewPayload.url.length > 0, 'artifact URL is non-empty'); -assert.equal(viewPayload.url.includes(token), false, 'artifact URL never embeds the general session JWT'); - -const unauthenticated = await jsonRequest(`/api/projects/${project.id}/attachments/${attachment.id}/view`); -assert.equal(unauthenticated.status, 401, 'view endpoint rejects missing Authorization'); - -const secondSignup = await jsonRequest('/api/auth/signup', { - method: 'POST', - body: JSON.stringify({ email: 'other-viewer@example.com', password: 'password123', name: 'Other Viewer' }), -}); -const otherToken = (await secondSignup.json()).token; -const crossTenant = await jsonRequest( - `/api/projects/${project.id}/attachments/${attachment.id}/view`, - { headers: { authorization: `Bearer ${otherToken}` } }, -); -assert.equal(crossTenant.status, 404, 'cross-tenant attachment view remains nondisclosing'); - -console.log('attachment view authentication boundary tests passed'); From 766eb2d2d48d3eaef66fdb093b5aa54abf3ce55c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 00:36:21 +0900 Subject: [PATCH 9/9] chore(security): restore XML PR test surface --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d513d0ef..46d07bfb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/attachment-view-auth.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",