From 9f7de2968c186e261adb1beae38b52dce2dd1de7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 14:46:21 +0900 Subject: [PATCH 1/5] test(clearfolio): require explicit artifact origin trust --- package.json | 4 +- .../unit/clearfolio-artifact-origin.test.mjs | 111 ++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 tests/unit/clearfolio-artifact-origin.test.mjs diff --git a/package.json b/package.json index 4134834c..ce17f8ce 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "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: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/clearfolio-provider-boundary.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: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/clearfolio-provider-boundary.test.mjs && node tests/unit/clearfolio-artifact-origin.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/clearfolio-provider-boundary.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", + "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/clearfolio-provider-boundary.test.mjs && node tests/unit/clearfolio-artifact-origin.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", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", diff --git a/tests/unit/clearfolio-artifact-origin.test.mjs b/tests/unit/clearfolio-artifact-origin.test.mjs new file mode 100644 index 00000000..2094c39f --- /dev/null +++ b/tests/unit/clearfolio-artifact-origin.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; +const originalFetch = globalThis.fetch; +let importSequence = 0; +let fetchCalls = 0; +let artifactPayload = { artifactUrl: 'https://clearfolio.example/file.pdf' }; + +globalThis.fetch = async () => { + fetchCalls += 1; + return new Response(JSON.stringify(artifactPayload), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +}; + +async function loadAdapter(artifactOrigins) { + process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; + delete process.env.SCOPEWEAVE_DEV; + if (artifactOrigins === undefined) delete process.env.CLEARFOLIO_ARTIFACT_ORIGINS; + else process.env.CLEARFOLIO_ARTIFACT_ORIGINS = artifactOrigins; + importSequence += 1; + return import(`../../server/clearfolio.mjs?artifact-origin-policy=${importSequence}`); +} + +async function resolveArtifact(link, artifactOrigins) { + artifactPayload = { artifactUrl: link }; + const { artifactUrl } = await loadAdapter(artifactOrigins); + return artifactUrl(4, 5, 'job-1'); +} + +test.after(() => { + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; + delete process.env.CLEARFOLIO_ARTIFACT_ORIGINS; + delete process.env.SCOPEWEAVE_DEV; +}); + +test('artifact URLs default to the configured Clearfolio origin', async () => { + await assert.rejects( + () => resolveArtifact('https://cdn.example/file.pdf', undefined), + /clearfolio artifact-link response invalid/, + ); + assert.equal( + await resolveArtifact('/signed/file.pdf', undefined), + 'https://clearfolio.example/signed/file.pdf', + ); +}); + +test('explicit HTTPS artifact origins are exact scheme-host-port allowlist entries', async () => { + assert.equal( + await resolveArtifact('https://cdn.example/file.pdf', ' https://cdn.example '), + 'https://cdn.example/file.pdf', + ); + assert.equal( + await resolveArtifact( + 'https://cdn.example/file.pdf?artifactToken=remote%20token', + 'https://cdn.example', + ), + 'https://cdn.example/file.pdf?artifactToken=remote%20token', + 'an allowlisted cross-origin token remains bound to the returned origin', + ); + await assert.rejects( + () => resolveArtifact('https://cdn.example/file.pdf', 'https://cdn.example:8443'), + /clearfolio artifact-link response invalid/, + 'an allowlist entry with a different port is a different origin', + ); +}); + +test('artifact links reject credentials and fragments even on trusted origins', async () => { + for (const link of [ + 'https://user:password@clearfolio.example/file.pdf', + 'https://clearfolio.example/file.pdf#secret-fragment', + 'https://user:password@cdn.example/file.pdf', + 'https://cdn.example/file.pdf#secret-fragment', + ]) { + await assert.rejects( + () => resolveArtifact(link, 'https://cdn.example'), + /clearfolio artifact-link response invalid/, + ); + } +}); + +test('artifact origin configuration fails closed before provider transport', async () => { + const invalidConfigurations = [ + 'http://cdn.example', + 'https://user:password@cdn.example', + 'https://cdn.example/path', + 'https://cdn.example?query=1', + 'https://cdn.example#fragment', + 'not a URL', + 'https://cdn.example,', + ]; + + for (const artifactOrigins of invalidConfigurations) { + const before = fetchCalls; + artifactPayload = { artifactUrl: 'https://cdn.example/file.pdf' }; + const { artifactUrl } = await loadAdapter(artifactOrigins); + await assert.rejects( + () => artifactUrl(4, 5, 'job-1'), + (error) => { + assert.equal(error.code, 'clearfolio_artifact_origins_invalid'); + return true; + }, + ); + assert.equal(fetchCalls, before, `invalid allowlist ${artifactOrigins} never reaches provider transport`); + } +}); From ce4e7c574a5a02134e76d2fb03d69017952b7635 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 14:54:28 +0900 Subject: [PATCH 2/5] fix(clearfolio): enforce artifact origin trust --- server/clearfolio.mjs | 60 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index c93be182..fd716524 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -122,6 +122,58 @@ function clearfolioConfiguration() { }; } +/** + * Build the exact set of origins trusted to host Clearfolio artifacts. + * + * The configured Clearfolio origin is always trusted. Additional origins are + * optional and must be comma-separated HTTPS origins with no credentials, + * path, query, or fragment. Canonical URL origins preserve exact scheme, host, + * and effective port identity while preventing string-prefix allowlist bypasses. + * + * @param {string} baseUrl - Validated Clearfolio provider origin. + * @returns {Set} Canonical origins accepted for artifact redirects. + * @throws {ClearfolioConfigurationError} If the optional allowlist is malformed or unsafe. + */ +function clearfolioArtifactOrigins(baseUrl) { + const trustedOrigins = new Set([new URL(baseUrl).origin]); + const configuredOrigins = process.env.CLEARFOLIO_ARTIFACT_ORIGINS; + if (configuredOrigins === undefined) return trustedOrigins; + + const entries = String(configuredOrigins).split(','); + if (entries.some((entry) => entry.trim().length === 0)) { + throw new ClearfolioConfigurationError( + 'clearfolio_artifact_origins_invalid', + 'CLEARFOLIO_ARTIFACT_ORIGINS must contain only comma-separated HTTPS origins.', + ); + } + + for (const entry of entries) { + let url; + try { + url = new URL(entry.trim()); + } catch { + throw new ClearfolioConfigurationError( + 'clearfolio_artifact_origins_invalid', + 'CLEARFOLIO_ARTIFACT_ORIGINS must contain only comma-separated HTTPS origins.', + ); + } + if ( + url.protocol !== 'https:' + || Boolean(url.username + url.password) + || url.pathname !== '/' + || Boolean(url.search) + || Boolean(url.hash) + ) { + throw new ClearfolioConfigurationError( + 'clearfolio_artifact_origins_invalid', + 'CLEARFOLIO_ARTIFACT_ORIGINS must contain only comma-separated HTTPS origins.', + ); + } + trustedOrigins.add(url.origin); + } + return trustedOrigins; +} + /** * Sign tenant claims using the Clearfolio HMAC interoperability contract. * @@ -455,6 +507,7 @@ export async function artifactUrl(orgId, userId, jobId) { const canonicalJobId = validateJobId(jobId); const configuration = clearfolioConfiguration(); if (configuration.mock) return `/api/mock-clearfolio/${encodeURIComponent(canonicalJobId)}`; + const trustedArtifactOrigins = clearfolioArtifactOrigins(configuration.baseUrl); let res; try { res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(canonicalJobId)}/artifact-links`, { @@ -486,6 +539,13 @@ export async function artifactUrl(orgId, userId, jobId) { if (url.protocol !== 'https:' && !allowsHttp) { throw new Error('clearfolio artifact-link response invalid'); } + if ( + Boolean(url.username + url.password) + || Boolean(url.hash) + || !trustedArtifactOrigins.has(url.origin) + ) { + throw new Error('clearfolio artifact-link response invalid'); + } const token = url.searchParams.get('artifactToken'); if (token && url.origin === clearfolioUrl.origin) { From 462f6594c4d326152146a7403896381c8e34a270 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 14:55:29 +0900 Subject: [PATCH 3/5] docs(changelog): record artifact origin trust --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fce05d63..3501968e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 required a canonical signed production origin, rejected ambiguous provider URL components, and prevented cross-origin artifact tokens from being transplanted into the trusted Clearfolio viewer URL. +- Restricted hosted Clearfolio artifact redirects to the provider origin by + default; optional `CLEARFOLIO_ARTIFACT_ORIGINS` entries must be exact HTTPS + origins, so scheme/host/port changes, credentials, fragments, and unapproved + cross-origin links fail closed while approved cross-origin tokens remain bound + to the origin that issued them. - Bounded hosted Clearfolio calls to non-redirecting 15-second requests and 256 KiB streamed JSON responses, composed caller cancellation with the provider budget, and validated document metadata/bytes and provider job IDs From 865fec5a51cb0230f2e2830e0ee3dd15e534a901 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 14:55:53 +0900 Subject: [PATCH 4/5] docs(clearfolio): record artifact origin trust boundary --- .../clearfolio-artifact-origin-trust.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/doctoring/clearfolio-artifact-origin-trust.md diff --git a/docs/doctoring/clearfolio-artifact-origin-trust.md b/docs/doctoring/clearfolio-artifact-origin-trust.md new file mode 100644 index 00000000..38507d3d --- /dev/null +++ b/docs/doctoring/clearfolio-artifact-origin-trust.md @@ -0,0 +1,57 @@ +# Clearfolio artifact-origin trust boundary + +## Decision + +ScopeWeave treats every artifact link returned by Clearfolio as untrusted provider data. The configured `CLEARFOLIO_URL` origin is the default artifact trust boundary. A production operator may add reviewed CDN or object-storage origins through `CLEARFOLIO_ARTIFACT_ORIGINS`, but each entry must be an origin only: HTTPS scheme, host, and optional non-default port, with no credentials, path, query, fragment, or empty comma-separated entry. + +This slice is deliberately narrower than the complete Clearfolio production-adapter program in issue #489. It does not claim that provider DNS/IP authorization, artifact content validation, retention, or all operational acceptance work is complete. It closes the redirect-origin and token-confusion boundary on top of the provider-response controls owned by the parent PR. + +## Why exact origins + +RFC 6454 defines an origin around scheme, host, and port. Comparing canonical URL origins therefore keeps `https://cdn.example`, `https://cdn.example:8443`, and HTTP variants in distinct trust domains instead of relying on string-prefix matching. The WHATWG URL Standard supplies the parser and serialization semantics used by Node's `URL` implementation, including explicit username/password and fragment components. + +The adapter uses a positive allowlist rather than accepting any syntactically valid HTTPS URL. OWASP's SSRF guidance recommends allowlisting known destinations and disabling or tightly validating redirects when the intended service set is known. Although ScopeWeave is redirecting a browser to a provider-selected artifact rather than issuing a second server-side fetch, the same positive-trust principle prevents an untrusted provider response from turning the application into an arbitrary external redirector. + +## Runtime contract + +1. `CLEARFOLIO_ARTIFACT_ORIGINS` is optional. When absent, only the validated Clearfolio provider origin is trusted. +2. When present, the value is a comma-separated list of canonicalizable HTTPS origins. Whitespace around entries is ignored; empty entries are rejected. +3. Any malformed entry, HTTP entry, URL credential, path, query, or fragment produces `ClearfolioConfigurationError` with stable code `clearfolio_artifact_origins_invalid` before the artifact-link provider request is sent. +4. Provider-returned artifact URLs must still satisfy the existing HTTP/HTTPS and downgrade rules, must contain no credentials or fragment, and must resolve to the provider origin or an explicitly configured artifact origin. +5. A same-origin `artifactToken` may be translated into the trusted Clearfolio viewer route. A token on an approved cross-origin artifact URL remains on that returned URL; ScopeWeave never transplants it into the provider-origin viewer. +6. Exact origin comparison includes the effective port. Approving `https://cdn.example:8443` does not approve `https://cdn.example`. + +## Operator action + +If Clearfolio returns artifacts from a separate reviewed CDN or object-storage service, configure only that service origin, for example: + +```text +CLEARFOLIO_ARTIFACT_ORIGINS=https://artifacts.example.com,https://archive.example.com:8443 +``` + +Do not place signed paths, object keys, tokens, credentials, query strings, or fragments in this setting. If no cross-origin artifact service is required, leave the variable unset; the provider origin remains the least-privilege default. + +## Verification contract + +`tests/unit/clearfolio-artifact-origin.test.mjs` exercises the production adapter with real `URL` parsing and a bounded mocked provider response. It proves: + +- cross-origin HTTPS artifacts fail by default; +- same-origin relative artifacts continue to resolve against the provider; +- an explicitly approved origin succeeds only for the same scheme/host/port identity; +- approved cross-origin `artifactToken` values remain on the approved origin; +- credentials and fragments are rejected on provider and approved origins; +- malformed, HTTP, credentialed, path-, query-, fragment-bearing, and empty-entry configuration fails before any provider transport. + +The test is registered in both the normal unit suite and the production coverage cases so changes to this boundary cannot silently bypass repository coverage evidence. + +## Failure and rollback + +Configuration failure is fail-closed and limited to the artifact-link capability; it does not broaden trust or silently fall back to arbitrary URLs. Rollback removes the additional-origin feature and returns to provider-origin-only artifact redirects. Do not roll back by permitting arbitrary HTTPS destinations or by moving cross-origin tokens into a trusted same-origin viewer URL. + +## References + +Barth, A. (2011). *The web origin concept* (RFC 6454). Internet Engineering Task Force. https://doi.org/10.17487/RFC6454 + +Open Worldwide Application Security Project. (n.d.). *Server side request forgery prevention cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 15, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +WHATWG. (2026). *URL standard*. https://url.spec.whatwg.org/ From e4d2f5f02294fe3e337e075ee05ad2afc2889aa6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 14:58:20 +0900 Subject: [PATCH 5/5] test(clearfolio): align legacy artifact origin contract --- tests/unit/clearfolio-status-signal.test.mjs | 37 ++++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index 9ea164cb..70c54199 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -54,6 +54,7 @@ test.after(() => { globalThis.fetch = originalFetch; delete process.env.CLEARFOLIO_URL; delete process.env.CLEARFOLIO_HMAC_SECRET; + delete process.env.CLEARFOLIO_ARTIFACT_ORIGINS; delete process.env.SCOPEWEAVE_DEV; }); @@ -244,22 +245,27 @@ test('artifactUrl validates links and never exposes transport or response text', 'same-origin artifact tokens may be translated into the trusted viewer route', ); - setResponse({ json: async () => ({ url: 'https://cdn.example/file.pdf' }) }); - assert.equal( - await artifactUrl(4, 5, 'job-1'), - 'https://cdn.example/file.pdf', - ); + process.env.CLEARFOLIO_ARTIFACT_ORIGINS = 'https://cdn.example'; + try { + setResponse({ json: async () => ({ url: 'https://cdn.example/file.pdf' }) }); + assert.equal( + await artifactUrl(4, 5, 'job-1'), + 'https://cdn.example/file.pdf', + ); - setResponse({ - json: async () => ({ - signedUrl: 'https://cdn.example/file.pdf?artifactToken=token%20value', - }), - }); - assert.equal( - await artifactUrl(4, 5, 'job-1'), - 'https://cdn.example/file.pdf?artifactToken=token%20value', - 'a token from another origin is never transplanted into the trusted Clearfolio viewer', - ); + setResponse({ + json: async () => ({ + signedUrl: 'https://cdn.example/file.pdf?artifactToken=token%20value', + }), + }); + assert.equal( + await artifactUrl(4, 5, 'job-1'), + 'https://cdn.example/file.pdf?artifactToken=token%20value', + 'an explicitly trusted cross-origin token remains bound to its artifact origin', + ); + } finally { + delete process.env.CLEARFOLIO_ARTIFACT_ORIGINS; + } }); test('artifactUrl permits HTTP only for explicit loopback development', async () => { @@ -277,6 +283,7 @@ test('artifactUrl permits HTTP only for explicit loopback development', async () ); } finally { process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + delete process.env.CLEARFOLIO_ARTIFACT_ORIGINS; delete process.env.SCOPEWEAVE_DEV; } });