From 338b3cc91648a90cb6b58ce37f48cc364a3f269d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:17:48 +0900 Subject: [PATCH 01/20] fix(clearfolio): fail closed when production is unconfigured --- server/clearfolio.mjs | 166 +++++++++++++++++++++++++++++++++--------- 1 file changed, 133 insertions(+), 33 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 8933e961..edda808a 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -1,15 +1,114 @@ // Clearfolio(통합 문서 뷰어) 클라이언트 — 산출물 첨부 변환/열람 프록시. -// 실서버: CLEARFOLIO_URL(+선택 CLEARFOLIO_HMAC_SECRET) 설정 시 사용. -// 미설정 시 내장 MOCK(즉시 SUCCEEDED, 바이트 인메모리)으로 전 플로우 테스트 가능. +// Production never substitutes an absent provider with successful fake conversions. +// The in-memory adapter exists only behind the explicit SCOPEWEAVE_DEV=1 boundary. import { createHmac } from 'node:crypto'; -const CF_URL = (process.env.CLEARFOLIO_URL || '').replace(/\/$/, ''); -const CF_SECRET = process.env.CLEARFOLIO_HMAC_SECRET || ''; +const CF_URL_INPUT = String(process.env.CLEARFOLIO_URL || '').trim(); +const CF_SECRET = String(process.env.CLEARFOLIO_HMAC_SECRET || ''); const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; const CLEARFOLIO_JOB_STATUSES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); +const MIN_HMAC_SECRET_LENGTH = 32; +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); -/** Whether the process uses the in-memory Clearfolio development adapter. */ -export const clearfolioMock = !CF_URL; +/** Whether the process uses the explicit in-memory Clearfolio development adapter. */ +export const clearfolioMock = process.env.SCOPEWEAVE_DEV === '1' && !CF_URL_INPUT; + +/** Stable configuration error whose message is safe for browser/operator surfaces. */ +export class ClearfolioConfigurationError extends Error { + /** + * Create a machine-classifiable Clearfolio configuration failure. + * + * @param {string} code - Stable failure code for tests and operator handling. + * @param {string} message - Non-secret, non-provider diagnostic message. + */ + constructor(code, message) { + super(message); + this.name = 'ClearfolioConfigurationError'; + this.code = code; + } +} + +/** + * Resolve a safe Clearfolio runtime configuration. + * + * Production requires a root HTTPS origin and a non-trivial HMAC secret. HTTP + * loopback is available only in explicit development mode. Credentials, + * fragments, query strings, and configured URL paths are rejected so every + * request path is constructed by this adapter rather than inherited from + * operator input. + * + * @returns {{mock:true}|{mock:false,baseUrl:string,secret:string}} Runtime configuration. + * @throws {ClearfolioConfigurationError} If production configuration is incomplete or unsafe. + */ +function clearfolioConfiguration() { + if (clearfolioMock) return { mock: true }; + if (!CF_URL_INPUT) { + throw new ClearfolioConfigurationError( + 'clearfolio_not_configured', + 'Clearfolio is unavailable because CLEARFOLIO_URL is not configured.', + ); + } + + let url; + try { + url = new URL(CF_URL_INPUT); + } catch { + throw new ClearfolioConfigurationError( + 'clearfolio_url_invalid', + 'CLEARFOLIO_URL must be a valid absolute URL.', + ); + } + if (!['https:', 'http:'].includes(url.protocol)) { + throw new ClearfolioConfigurationError( + 'clearfolio_url_invalid', + 'CLEARFOLIO_URL must use HTTP or HTTPS.', + ); + } + if (url.username || url.password) { + throw new ClearfolioConfigurationError( + 'clearfolio_url_credentials_forbidden', + 'CLEARFOLIO_URL must not contain credentials.', + ); + } + if (url.search) { + throw new ClearfolioConfigurationError( + 'clearfolio_url_query_forbidden', + 'CLEARFOLIO_URL must not contain a query string.', + ); + } + if (url.hash) { + throw new ClearfolioConfigurationError( + 'clearfolio_url_fragment_forbidden', + 'CLEARFOLIO_URL must not contain a fragment.', + ); + } + if (url.pathname !== '/') { + throw new ClearfolioConfigurationError( + 'clearfolio_url_path_forbidden', + 'CLEARFOLIO_URL must identify the provider origin without a path.', + ); + } + + const isLoopback = LOOPBACK_HOSTNAMES.has(url.hostname); + if (url.protocol === 'http:' && !(process.env.SCOPEWEAVE_DEV === '1' && isLoopback)) { + throw new ClearfolioConfigurationError( + 'clearfolio_transport_insecure', + 'Clearfolio production traffic requires HTTPS.', + ); + } + if (!CF_SECRET.trim() || CF_SECRET.trim().length < MIN_HMAC_SECRET_LENGTH) { + throw new ClearfolioConfigurationError( + 'clearfolio_hmac_secret_invalid', + `CLEARFOLIO_HMAC_SECRET must contain at least ${MIN_HMAC_SECRET_LENGTH} non-whitespace characters.`, + ); + } + + return { + mock: false, + baseUrl: url.origin, + secret: CF_SECRET, + }; +} /** * Sign tenant claims using the Clearfolio HMAC interoperability contract. @@ -34,28 +133,26 @@ export function signClaims(tenantId, subjectId, permissions, issuedAt, secret) { * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. - * @returns {Record} Tenant, subject, permission, and optional HMAC headers. + * @param {string} secret - Validated shared HMAC secret. + * @returns {Record} Tenant, subject, permission, and HMAC headers. */ -function tenantHeaders(orgId, userId) { +function tenantHeaders(orgId, userId, secret) { const tenantId = `sw-org-${orgId}`; const subjectId = `sw-user-${userId}`; - const headers = { + const issuedAt = String(Math.floor(Date.now() / 1000)); + return { 'X-Clearfolio-Tenant-Id': tenantId, 'X-Clearfolio-Subject-Id': subjectId, 'X-Clearfolio-Permissions': PERMISSIONS, - }; - if (CF_SECRET) { - const issuedAt = String(Math.floor(Date.now() / 1000)); - headers['X-Clearfolio-Claims-Issued-At'] = issuedAt; - headers['X-Clearfolio-Claims-Signature'] = signClaims( + 'X-Clearfolio-Claims-Issued-At': issuedAt, + 'X-Clearfolio-Claims-Signature': signClaims( tenantId, subjectId, PERMISSIONS, issuedAt, - CF_SECRET, - ); - } - return headers; + secret, + ), + }; } /** @@ -84,20 +181,20 @@ function isClearfolioJobStatus(value) { return typeof value === 'string' && CLEARFOLIO_JOB_STATUSES.has(value); } -// ---- mock store (dev/test 전용; 재시작 시 소실) ---- +// ---- explicit development-only mock store (restart discards it) ---- const mockDocs = new Map(); // jobId -> { name, mime, bytes } let mockSeq = 0; /** - * Read one in-memory mock artifact. + * Read one in-memory mock artifact only when the development adapter is active. * * @param {string} jobId - Mock conversion job identifier. * @returns {{name:string,mime:string,bytes:Buffer}|null} Stored artifact or null. */ -export const mockArtifact = (jobId) => mockDocs.get(jobId) || null; +export const mockArtifact = (jobId) => (clearfolioMock ? mockDocs.get(jobId) || null : null); /** - * Submit a document conversion job through Clearfolio or the local mock. + * Submit a document conversion job through Clearfolio or the explicit local mock. * * Downstream response text and transport errors are never copied into the * thrown error because the caller may serialize that message to a browser. @@ -109,7 +206,8 @@ export const mockArtifact = (jobId) => mockDocs.get(jobId) || null; * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed response. */ export async function submitJob(orgId, userId, { name, mime, bytes }) { - if (clearfolioMock) { + const configuration = clearfolioConfiguration(); + if (configuration.mock) { const jobId = `mockcf-${++mockSeq}`; mockDocs.set(jobId, { name, mime, bytes }); return { jobId, status: 'SUCCEEDED' }; @@ -118,9 +216,9 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { form.append('file', new Blob([bytes], { type: mime || 'application/octet-stream' }), name); let res; try { - res = await fetch(`${CF_URL}/api/v1/convert/jobs`, { + res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs`, { method: 'POST', - headers: tenantHeaders(orgId, userId), + headers: tenantHeaders(orgId, userId, configuration.secret), body: form, }); } catch { @@ -156,11 +254,12 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed status. */ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { - if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; + const configuration = clearfolioConfiguration(); + if (configuration.mock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; let res; try { - res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), + res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId, configuration.secret), signal, }); } catch { @@ -189,12 +288,13 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns an invalid link. */ export async function artifactUrl(orgId, userId, jobId) { - if (clearfolioMock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; + const configuration = clearfolioConfiguration(); + if (configuration.mock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; let res; try { - res = await fetch(`${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { + res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { method: 'POST', - headers: tenantHeaders(orgId, userId), + headers: tenantHeaders(orgId, userId, configuration.secret), }); } catch { throw new Error('clearfolio artifact-link unavailable'); @@ -210,7 +310,7 @@ export async function artifactUrl(orgId, userId, jobId) { let url; let clearfolioUrl; try { - clearfolioUrl = new URL(CF_URL); + clearfolioUrl = new URL(configuration.baseUrl); url = new URL(link, clearfolioUrl); } catch { throw new Error('clearfolio artifact-link response invalid'); @@ -224,7 +324,7 @@ export async function artifactUrl(orgId, userId, jobId) { // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 없으면 검증한 URL. const token = url.searchParams.get('artifactToken'); if (token) { - return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; + return `${configuration.baseUrl}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; } return url.href; } From 9f223d92cbf33ee80ab36d987bdef29a6be3dcbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:18:27 +0900 Subject: [PATCH 02/20] test(clearfolio): prove explicit development and production config boundaries --- .../clearfolio-adapter-mock-hmac.test.mjs | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index 85ca5894..06d82cea 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -1,10 +1,34 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -test('Clearfolio mock adapter preserves artifacts and local status semantics', async () => { +const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; + +async function freshModule(label) { + return import(`../../server/clearfolio.mjs?${label}-${Date.now()}-${Math.random()}`); +} + +test('unconfigured production fails closed instead of creating fake conversions', async () => { + delete process.env.SCOPEWEAVE_DEV; delete process.env.CLEARFOLIO_URL; delete process.env.CLEARFOLIO_HMAC_SECRET; - const mock = await import('../../server/clearfolio.mjs?mock-adapter-contract-test=1'); + const production = await freshModule('unconfigured-production'); + + assert.equal(production.clearfolioMock, false); + assert.equal(production.mockArtifact('missing-job'), null); + for (const operation of [ + () => production.submitJob(11, 12, { name: 'mock.txt', mime: 'text/plain', bytes: Buffer.from('x') }), + () => production.jobStatus(11, 12, 'job-1'), + () => production.artifactUrl(11, 12, 'job-1'), + ]) { + await assert.rejects(operation, (error) => error.code === 'clearfolio_not_configured'); + } +}); + +test('Clearfolio mock adapter exists only in explicit development mode', async () => { + process.env.SCOPEWEAVE_DEV = '1'; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; + const mock = await freshModule('mock-adapter-contract'); assert.equal(mock.clearfolioMock, true); assert.equal(mock.mockArtifact('missing-job'), null); @@ -28,11 +52,56 @@ test('Clearfolio mock adapter preserves artifacts and local status semantics', a await mock.artifactUrl(11, 12, 'job/with space'), '/api/mock-clearfolio/job%2Fwith%20space', ); + delete process.env.SCOPEWEAVE_DEV; +}); + +test('production URL and HMAC configuration rejects ambiguous or unsafe input', async () => { + delete process.env.SCOPEWEAVE_DEV; + const cases = [ + ['ftp://clearfolio.example', HMAC_SECRET, 'clearfolio_url_invalid'], + ['http://clearfolio.example', HMAC_SECRET, 'clearfolio_transport_insecure'], + ['https://user:pass@clearfolio.example', HMAC_SECRET, 'clearfolio_url_credentials_forbidden'], + ['https://clearfolio.example?tenant=x', HMAC_SECRET, 'clearfolio_url_query_forbidden'], + ['https://clearfolio.example#fragment', HMAC_SECRET, 'clearfolio_url_fragment_forbidden'], + ['https://clearfolio.example/base', HMAC_SECRET, 'clearfolio_url_path_forbidden'], + ['https://clearfolio.example', 'short-secret', 'clearfolio_hmac_secret_invalid'], + ]; + + for (const [url, secret, code] of cases) { + process.env.CLEARFOLIO_URL = url; + process.env.CLEARFOLIO_HMAC_SECRET = secret; + const configured = await freshModule(`invalid-${code}`); + await assert.rejects( + () => configured.jobStatus(1, 2, 'job-1'), + (error) => error.code === code, + `${url} should fail with ${code}`, + ); + } + + process.env.SCOPEWEAVE_DEV = '1'; + process.env.CLEARFOLIO_URL = 'http://127.0.0.1:8080'; + process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; + const loopback = await freshModule('development-loopback-http'); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ status: 'RUNNING' }), + }); + try { + assert.equal(await loopback.jobStatus(1, 2, 'job-1'), 'RUNNING'); + } finally { + globalThis.fetch = originalFetch; + delete process.env.SCOPEWEAVE_DEV; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; + } }); test('Clearfolio tenant claim headers use the documented HMAC contract', async () => { + delete process.env.SCOPEWEAVE_DEV; process.env.CLEARFOLIO_URL = 'https://clearfolio.example/'; - process.env.CLEARFOLIO_HMAC_SECRET = 'clearfolio-shared-secret'; + process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; const originalFetch = globalThis.fetch; const originalNow = Date.now; let observedUrl; @@ -49,7 +118,7 @@ test('Clearfolio tenant claim headers use the documented HMAC contract', async ( }; try { - const signed = await import('../../server/clearfolio.mjs?hmac-header-contract-test=1'); + const signed = await freshModule('hmac-header-contract'); assert.equal(signed.clearfolioMock, false); assert.equal(await signed.jobStatus(21, 34, 'signed-job'), 'RUNNING'); assert.equal( @@ -72,7 +141,7 @@ test('Clearfolio tenant claim headers use the documented HMAC contract', async ( 'sw-user-34', 'job:create,job:read,viewer:read,artifact-link:create', issuedAt, - 'clearfolio-shared-secret', + HMAC_SECRET, ), ); assert.doesNotMatch( From 6f9985c145adf9aa9c70032d55eeda53f84249cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:19:34 +0900 Subject: [PATCH 03/20] test(clearfolio): enforce signed production and token-origin boundaries --- tests/unit/clearfolio-status-signal.test.mjs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index cf3ad02c..b8d3e4e2 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -1,7 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +const HMAC_SECRET = 'clearfolio-shared-secret-32-bytes!!'; process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; +process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; const originalFetch = globalThis.fetch; let observedUrl; let observedOptions; @@ -40,6 +42,8 @@ async function expectSanitizedFailure(operation, expectedMessage, forbiddenPatte test.after(() => { globalThis.fetch = originalFetch; delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; + delete process.env.SCOPEWEAVE_DEV; }); test('jobStatus enforces endpoint, signal, transport, HTTP, and status contracts', async () => { @@ -230,22 +234,26 @@ test('artifactUrl validates links and never exposes transport or response text', }); assert.equal( await artifactUrl(4, 5, 'job-1'), - 'https://clearfolio.example/viewer/job-1?artifactToken=token%20value', + 'https://cdn.example/file.pdf?artifactToken=token%20value', + 'a token from another origin is never transplanted into the trusted Clearfolio viewer', ); }); -test('artifactUrl permits HTTP only when the configured Clearfolio endpoint is HTTP', async () => { - process.env.CLEARFOLIO_URL = 'http://clearfolio.local'; +test('artifactUrl permits HTTP only for explicit loopback development', async () => { + process.env.SCOPEWEAVE_DEV = '1'; + process.env.CLEARFOLIO_URL = 'http://127.0.0.1:8080'; + process.env.CLEARFOLIO_HMAC_SECRET = HMAC_SECRET; try { const { artifactUrl: httpArtifactUrl } = await import( '../../server/clearfolio.mjs?http-artifact-contract-test=1' ); - setResponse({ json: async () => ({ artifactUrl: 'http://cdn.local/file.pdf' }) }); + setResponse({ json: async () => ({ artifactUrl: 'http://127.0.0.1:8080/file.pdf' }) }); assert.equal( await httpArtifactUrl(4, 5, 'job-http'), - 'http://cdn.local/file.pdf', + 'http://127.0.0.1:8080/file.pdf', ); } finally { process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; + delete process.env.SCOPEWEAVE_DEV; } }); From d330ff60d1b758d10ab2f8a7ceb47f93601a1f83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:20:27 +0900 Subject: [PATCH 04/20] fix(clearfolio): bind viewer tokens to their returned origin --- server/clearfolio.mjs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index edda808a..704723e3 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -276,10 +276,9 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { /** * Issue a viewable artifact URL for a completed Clearfolio job. * - * The hosted path prefers Clearfolio's external PDF.js viewer when an - * `artifactToken` is available and otherwise returns a validated HTTP(S) URL. - * Downstream response text and transport errors are never exposed to callers. - * An HTTPS Clearfolio deployment cannot downgrade an artifact link to HTTP. + * Same-origin `artifactToken` values may be translated into the local viewer + * route. A token returned on another origin remains bound to that origin and is + * never transplanted into the trusted Clearfolio viewer URL. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -320,10 +319,8 @@ export async function artifactUrl(orgId, userId, jobId) { throw new Error('clearfolio artifact-link response invalid'); } - // PDF.js 뷰어 페이지 우선(clearfolio external artifactToken 모드): 토큰을 - // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 없으면 검증한 URL. const token = url.searchParams.get('artifactToken'); - if (token) { + if (token && url.origin === clearfolioUrl.origin) { return `${configuration.baseUrl}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; } return url.href; From 4721551f8fb70d3257cb2c73e557d8f5428754a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:20:56 +0900 Subject: [PATCH 05/20] test(clearfolio): make attachment mock mode explicit --- tests/api/attachment-status.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/attachment-status.test.mjs b/tests/api/attachment-status.test.mjs index 51bd5ea0..226f7034 100644 --- a/tests/api/attachment-status.test.mjs +++ b/tests/api/attachment-status.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; From 46320000ebac76cbb00a726444bc74097605b5a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:21:54 +0900 Subject: [PATCH 06/20] docs(deploy): make Clearfolio production readiness explicit --- docs/deploy.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 0cfdb799..fba01ca1 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,18 +34,33 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Startup fails unless it contains at least 32 non-whitespace characters. | | `PORT` | no (default 8787) | Listen port | | `SCOPEWEAVE_DB` | no (default `/data/scopeweave.db`) | SQLite file path (on the volume) | -| `SCOPEWEAVE_DEV` | no | Must be `1` to enable the dev `activate-pro` endpoint. **Never set in production.** | +| `SCOPEWEAVE_DEV` | no | Must be `1` to enable development-only behavior, including `activate-pro`, loopback Clearfolio HTTP, and the in-memory Clearfolio adapter when no provider URL exists. **Never set in production.** | | `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | for live billing | Enables real Stripe Checkout (`npm i stripe` too). Without them, billing uses the mock path. | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Unset → a built-in mock IdP (dev/test only). | | `ORCHESTRATOR_URL` | for AI 브리핑 | contextual-orchestrator 주소. Unset → deterministic mock. | | `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | -| `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). | -| `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). | +| `CLEARFOLIO_URL` | for production 산출물 viewer | Root Clearfolio service origin. Production requires HTTPS and rejects credentials, paths, query strings, and fragments. When absent in production, document conversion/viewing is unavailable rather than simulated. | +| `CLEARFOLIO_HMAC_SECRET` | with URL | Required tenant-claim HMAC secret; must contain at least 32 non-whitespace characters and match Clearfolio's configured verifier secret. | | `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | | `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. | | `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. | | `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-IP fixed-window rate limiting (429 + Retry-After). Off when unset. | +### Clearfolio capability readiness + +An unset `CLEARFOLIO_URL` is not a successful production conversion service. +Outside explicit `SCOPEWEAVE_DEV=1`, Clearfolio operations fail closed with a +stable configuration error and the mock artifact route is not registered. Other +ScopeWeave planning capabilities remain available. For local integration work, +`SCOPEWEAVE_DEV=1` permits the in-memory adapter when the URL is absent and also +permits HTTP only for `localhost`, `127.0.0.1`, or `::1`; remote HTTP endpoints +are rejected. + +Provider URLs are treated as service origins, not arbitrary request prefixes. +Keep credentials in the dedicated HMAC secret setting rather than URL userinfo, +and do not configure a path, query string, or fragment. The adapter constructs +its own versioned API paths from the validated origin. + ## Attachment status refresh operations The attachment-list API reads `job_id` in its initial project-scoped query and From 45808bf1c4f9cfcfc6df571d46ffd1ef65e099c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:22:20 +0900 Subject: [PATCH 07/20] docs(doctoring): record Clearfolio fail-closed configuration evidence --- .../clearfolio-production-configuration.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/doctoring/clearfolio-production-configuration.md diff --git a/docs/doctoring/clearfolio-production-configuration.md b/docs/doctoring/clearfolio-production-configuration.md new file mode 100644 index 00000000..e357cd80 --- /dev/null +++ b/docs/doctoring/clearfolio-production-configuration.md @@ -0,0 +1,49 @@ +# Clearfolio production configuration boundary + +## Decision + +ScopeWeave treats Clearfolio as an optional production capability, not as an implicit successful mock. The in-memory converter is available only when `SCOPEWEAVE_DEV=1` and no provider URL is configured. Outside that explicit development boundary, an absent provider produces the stable `clearfolio_not_configured` failure and the mock artifact route is not registered. + +A configured production provider must be a root HTTPS origin. ScopeWeave parses the operator value with the platform `URL` implementation and rejects URL credentials, query strings, fragments, and configured paths before building any downstream endpoint. HTTP is limited to explicit development mode on `localhost`, `127.0.0.1`, or `::1`. The tenant-claim HMAC secret is mandatory with a configured provider and must contain at least 32 non-whitespace characters. + +This boundary prevents configuration text from becoming an arbitrary downstream request prefix and prevents a production deployment from persisting fake `SUCCEEDED` conversion state merely because an integration is absent. It also preserves independent ScopeWeave operation: planning functionality remains available while document conversion/viewing fails closed with an actionable configuration error. + +## Artifact-token origin rule + +If Clearfolio returns an `artifactToken`, ScopeWeave rewrites it into the trusted Clearfolio viewer route only when the returned URL has the same origin as the configured Clearfolio service. A token supplied on another origin stays bound to that returned origin and is never transplanted into the trusted viewer URL. This closes a token-confusion boundary without claiming that arbitrary cross-origin artifact hosts are approved. + +Issue #489 remains open after this slice. A subsequent bounded change must still implement the explicit reviewed artifact-origin allowlist, redirect policy, streaming response-size/media-type limits, provider-wide request budget, and the remaining resource/lifecycle acceptance criteria before the Clearfolio adapter can be described as fully production-complete. + +## Executable evidence + +`tests/unit/clearfolio-adapter-mock-hmac.test.mjs` proves: + +- production without `CLEARFOLIO_URL` does not enable the mock and fails submit/status/artifact operations closed; +- the mock works only under explicit `SCOPEWEAVE_DEV=1`; +- unsupported schemes, remote HTTP, URL credentials, query strings, fragments, configured paths, and weak HMAC secrets are rejected; +- loopback HTTP is accepted only under explicit development mode; and +- signed tenant headers retain the documented canonical HMAC contract. + +`tests/unit/clearfolio-status-signal.test.mjs` continues to exercise sanitized transport/HTTP/JSON/status/artifact failures and now proves that cross-origin artifact tokens are not moved into the Clearfolio viewer origin. `tests/api/attachment-status.test.mjs` makes its test-only in-memory provider explicit instead of relying on an unset production URL. + +The shipped `server/clearfolio.mjs` remains in the canonical c8 production coverage target, so the new configuration branches execute under the repository coverage gate rather than a documentation-only path. + +## Standards and threat rationale + +The WHATWG URL Standard defines URL components, including credentials, queries, and fragments, and provides the common parsing model used by the JavaScript `URL` API. ScopeWeave parses first and then applies component-level policy instead of relying on string-prefix validation. + +OWASP's SSRF Prevention guidance recommends strict allowlisting and warns that redirects and attacker-controlled complete URLs can bypass URL validation. This slice narrows operator configuration to a provider origin and keeps request paths adapter-owned. The remaining redirect and artifact-host controls stay explicitly tracked by issue #489 rather than being implied by this narrower change. + +NIST SSDF 1.1 recommends identifying and maintaining software security requirements and producing well-secured software through repeatable verification. The fail-closed configuration contract, executable negative tests, and explicit remaining-gap statement provide acquisition-review evidence without claiming certification. + +## Rollback + +Rollback reverts the Clearfolio configuration parser, explicit development-mode tests, token-origin rule, deployment text, this doctoring record, and the corresponding CHANGELOG entry together. No database schema or persisted attachment representation changes in this slice. + +## References + +National Institute of Standards and Technology. (2022). *Secure Software Development Framework (SSDF) Version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +OWASP Foundation. (n.d.). *Server Side Request Forgery Prevention Cheat Sheet*. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +WHATWG. (2026). *URL Standard*. https://url.spec.whatwg.org/ From 3facf192c573076cded72a3b6ba935fb19771e2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:22:47 +0900 Subject: [PATCH 08/20] docs(changelog): record Clearfolio production config boundary --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 787ee51b..f035f675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Confined the in-memory Clearfolio adapter to explicit development mode, + 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. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden From 928782c876ee6c166e2d1477a975c59874935f49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:24:10 +0900 Subject: [PATCH 09/20] test(clearfolio): cover malformed provider origins --- tests/unit/clearfolio-adapter-mock-hmac.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index 06d82cea..cc3930b1 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -58,6 +58,7 @@ test('Clearfolio mock adapter exists only in explicit development mode', async ( test('production URL and HMAC configuration rejects ambiguous or unsafe input', async () => { delete process.env.SCOPEWEAVE_DEV; const cases = [ + ['not a url', HMAC_SECRET, 'clearfolio_url_invalid'], ['ftp://clearfolio.example', HMAC_SECRET, 'clearfolio_url_invalid'], ['http://clearfolio.example', HMAC_SECRET, 'clearfolio_transport_insecure'], ['https://user:pass@clearfolio.example', HMAC_SECRET, 'clearfolio_url_credentials_forbidden'], From 3eecbdef60ea68d430166cef459e3036e1673277 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:24:54 +0900 Subject: [PATCH 10/20] test(clearfolio): cover same-origin viewer token translation --- tests/unit/clearfolio-status-signal.test.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index b8d3e4e2..fea68573 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -221,6 +221,15 @@ test('artifactUrl validates links and never exposes transport or response text', 'https://clearfolio.example/signed/file.pdf', ); + setResponse({ json: async () => ({ + signedUrl: 'https://clearfolio.example/file.pdf?artifactToken=same%20origin', + }) }); + assert.equal( + await artifactUrl(4, 5, 'job-1'), + 'https://clearfolio.example/viewer/job-1?artifactToken=same%20origin', + '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'), From 90bd67260209519fecfdd1b64da13b8b9c36b933 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:57:09 +0900 Subject: [PATCH 11/20] test(clearfolio): require 32 non-whitespace HMAC characters --- tests/unit/clearfolio-adapter-mock-hmac.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index cc3930b1..c257aeca 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -66,6 +66,11 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', ['https://clearfolio.example#fragment', HMAC_SECRET, 'clearfolio_url_fragment_forbidden'], ['https://clearfolio.example/base', HMAC_SECRET, 'clearfolio_url_path_forbidden'], ['https://clearfolio.example', 'short-secret', 'clearfolio_hmac_secret_invalid'], + [ + 'https://clearfolio.example', + `${'a'.repeat(31)} ${' '.repeat(32)}`, + 'clearfolio_hmac_secret_invalid', + ], ]; for (const [url, secret, code] of cases) { From e46dd512e8f0190d07be907e6f7a7407a38e7678 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:59:01 +0900 Subject: [PATCH 12/20] test(clearfolio): prove internal whitespace cannot satisfy secret length --- tests/unit/clearfolio-adapter-mock-hmac.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index c257aeca..74d1417f 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -68,7 +68,7 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', ['https://clearfolio.example', 'short-secret', 'clearfolio_hmac_secret_invalid'], [ 'https://clearfolio.example', - `${'a'.repeat(31)} ${' '.repeat(32)}`, + `${'a'.repeat(16)}${' '.repeat(40)}${'b'.repeat(15)}`, 'clearfolio_hmac_secret_invalid', ], ]; From c8da068538e5f5032136f0a39a68ab387edc3646 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:59:46 +0900 Subject: [PATCH 13/20] fix(clearfolio): count only non-whitespace secret characters --- server/clearfolio.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 704723e3..da17bbea 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -96,7 +96,7 @@ function clearfolioConfiguration() { 'Clearfolio production traffic requires HTTPS.', ); } - if (!CF_SECRET.trim() || CF_SECRET.trim().length < MIN_HMAC_SECRET_LENGTH) { + if (CF_SECRET.replace(/\s/g, '').length < MIN_HMAC_SECRET_LENGTH) { throw new ClearfolioConfigurationError( 'clearfolio_hmac_secret_invalid', `CLEARFOLIO_HMAC_SECRET must contain at least ${MIN_HMAC_SECRET_LENGTH} non-whitespace characters.`, From 516429fa4c3a3ab1134283996129ee2a74e1c85f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 07:34:49 +0900 Subject: [PATCH 14/20] test(clearfolio): cover IPv6 loopback development URL --- tests/unit/clearfolio-adapter-mock-hmac.test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index 74d1417f..fb48b570 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -96,6 +96,14 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', }); try { assert.equal(await loopback.jobStatus(1, 2, 'job-1'), 'RUNNING'); + + process.env.CLEARFOLIO_URL = 'http://[::1]:8080'; + const ipv6Loopback = await freshModule('development-ipv6-loopback-http'); + assert.equal( + await ipv6Loopback.jobStatus(1, 2, 'job-1'), + 'RUNNING', + 'explicit development mode accepts the IPv6 loopback origin documented by the adapter', + ); } finally { globalThis.fetch = originalFetch; delete process.env.SCOPEWEAVE_DEV; From 5062a8d0601532b1563a0ceda035aea80ac2964b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 07:35:30 +0900 Subject: [PATCH 15/20] fix(clearfolio): recognize IPv6 loopback URL hostname --- server/clearfolio.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index da17bbea..2321d3dd 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -8,7 +8,8 @@ const CF_SECRET = String(process.env.CLEARFOLIO_HMAC_SECRET || ''); const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; const CLEARFOLIO_JOB_STATUSES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); const MIN_HMAC_SECRET_LENGTH = 32; -const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +// WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`). +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); /** Whether the process uses the explicit in-memory Clearfolio development adapter. */ export const clearfolioMock = process.env.SCOPEWEAVE_DEV === '1' && !CF_URL_INPUT; From 4953fba5734d92ed6ae4df11272d967b8f653896 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:42:56 +0900 Subject: [PATCH 16/20] test(clearfolio): reject cross-origin artifact tokens --- tests/unit/clearfolio-status-signal.test.mjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index fea68573..0f30e231 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -241,10 +241,9 @@ test('artifactUrl validates links and never exposes transport or response text', 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', + await expectSanitizedFailure( + () => artifactUrl(4, 5, 'job-1'), + 'clearfolio artifact-link response invalid', ); }); From b66180d556ce5af48d97b470c7196b2e7d583271 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:45:02 +0900 Subject: [PATCH 17/20] fix(clearfolio): reject cross-origin artifact tokens --- server/clearfolio.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 2321d3dd..cb2b5f4b 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -277,9 +277,10 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { /** * Issue a viewable artifact URL for a completed Clearfolio job. * - * Same-origin `artifactToken` values may be translated into the local viewer - * route. A token returned on another origin remains bound to that origin and is - * never transplanted into the trusted Clearfolio viewer URL. + * Same-origin `artifactToken` values may be translated into the trusted viewer + * route. Token-bearing links from another origin are rejected until an explicit + * reviewed artifact-origin allowlist exists; tokens are never transplanted or + * returned to an unreviewed cross-origin host. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -321,7 +322,10 @@ export async function artifactUrl(orgId, userId, jobId) { } const token = url.searchParams.get('artifactToken'); - if (token && url.origin === clearfolioUrl.origin) { + if (token) { + if (url.origin !== clearfolioUrl.origin) { + throw new Error('clearfolio artifact-link response invalid'); + } return `${configuration.baseUrl}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; } return url.href; From 3834cf26bef38e0eb336247b1ab503cd2c22db09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 10:46:34 +0900 Subject: [PATCH 18/20] docs(clearfolio): record fail-closed cross-origin token rule --- docs/doctoring/clearfolio-production-configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/clearfolio-production-configuration.md b/docs/doctoring/clearfolio-production-configuration.md index e357cd80..2f1b1501 100644 --- a/docs/doctoring/clearfolio-production-configuration.md +++ b/docs/doctoring/clearfolio-production-configuration.md @@ -10,7 +10,7 @@ This boundary prevents configuration text from becoming an arbitrary downstream ## Artifact-token origin rule -If Clearfolio returns an `artifactToken`, ScopeWeave rewrites it into the trusted Clearfolio viewer route only when the returned URL has the same origin as the configured Clearfolio service. A token supplied on another origin stays bound to that returned origin and is never transplanted into the trusted viewer URL. This closes a token-confusion boundary without claiming that arbitrary cross-origin artifact hosts are approved. +If Clearfolio returns an `artifactToken`, ScopeWeave rewrites it into the trusted Clearfolio viewer route only when the returned URL has the same origin as the configured Clearfolio service. A token-bearing link from another origin is rejected rather than transplanted into the trusted viewer or returned directly to an unreviewed host. This closes the token-confusion boundary without claiming that arbitrary cross-origin artifact hosts are approved. Issue #489 remains open after this slice. A subsequent bounded change must still implement the explicit reviewed artifact-origin allowlist, redirect policy, streaming response-size/media-type limits, provider-wide request budget, and the remaining resource/lifecycle acceptance criteria before the Clearfolio adapter can be described as fully production-complete. @@ -24,7 +24,7 @@ Issue #489 remains open after this slice. A subsequent bounded change must still - loopback HTTP is accepted only under explicit development mode; and - signed tenant headers retain the documented canonical HMAC contract. -`tests/unit/clearfolio-status-signal.test.mjs` continues to exercise sanitized transport/HTTP/JSON/status/artifact failures and now proves that cross-origin artifact tokens are not moved into the Clearfolio viewer origin. `tests/api/attachment-status.test.mjs` makes its test-only in-memory provider explicit instead of relying on an unset production URL. +`tests/unit/clearfolio-status-signal.test.mjs` continues to exercise sanitized transport/HTTP/JSON/status/artifact failures and now proves that a cross-origin token-bearing artifact link fails closed rather than moving the token into the Clearfolio viewer or returning it to an unreviewed host. `tests/api/attachment-status.test.mjs` makes its test-only in-memory provider explicit instead of relying on an unset production URL. The shipped `server/clearfolio.mjs` remains in the canonical c8 production coverage target, so the new configuration branches execute under the repository coverage gate rather than a documentation-only path. From d122ca752e4870f5ffe5cf2729d4abf46d318210 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:45:53 +0900 Subject: [PATCH 19/20] docs(deploy): align orchestrator fail-closed contract --- docs/deploy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index fba01ca1..83a099b3 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,11 +34,11 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Startup fails unless it contains at least 32 non-whitespace characters. | | `PORT` | no (default 8787) | Listen port | | `SCOPEWEAVE_DB` | no (default `/data/scopeweave.db`) | SQLite file path (on the volume) | -| `SCOPEWEAVE_DEV` | no | Must be `1` to enable development-only behavior, including `activate-pro`, loopback Clearfolio HTTP, and the in-memory Clearfolio adapter when no provider URL exists. **Never set in production.** | +| `SCOPEWEAVE_DEV` | no | Must be `1` to enable development-only behavior, including `activate-pro`, deterministic orchestrator responses, loopback Clearfolio HTTP, and the in-memory Clearfolio adapter when no provider URL exists. **Never set in production.** | | `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | for live billing | Enables real Stripe Checkout (`npm i stripe` too). Without them, billing uses the mock path. | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Unset → a built-in mock IdP (dev/test only). | -| `ORCHESTRATOR_URL` | for AI 브리핑 | contextual-orchestrator 주소. Unset → deterministic mock. | -| `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | +| `ORCHESTRATOR_URL` | for production AI briefing | Root contextual-orchestrator service origin. Production briefing fails closed when it is absent; deterministic responses exist only with `SCOPEWEAVE_DEV=1`. | +| `ORCHESTRATOR_TOKEN` | with URL | Required bearer token for the configured contextual-orchestrator service (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | | `CLEARFOLIO_URL` | for production 산출물 viewer | Root Clearfolio service origin. Production requires HTTPS and rejects credentials, paths, query strings, and fragments. When absent in production, document conversion/viewing is unavailable rather than simulated. | | `CLEARFOLIO_HMAC_SECRET` | with URL | Required tenant-claim HMAC secret; must contain at least 32 non-whitespace characters and match Clearfolio's configured verifier secret. | | `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | From 5c2f73d84d4a44bd138d94a61fe4a702adebada9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:59:29 +0000 Subject: [PATCH 20/20] fix(clearfolio): fail closed on provider redirects and unreviewed hosts Stop following Clearfolio 3xx responses so tenant HMAC headers cannot be replayed onto another origin. Reject token-free cross-origin, protocol- relative, credentialed, and fragmented artifact links instead of issuing an unreviewed attachment-view 302. Trim secret-file whitespace before HMAC signing and prove remote HTTP stays rejected in development. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 6 +++ CHANGELOG.md | 6 ++- docs/deploy.md | 6 ++- .../clearfolio-production-configuration.md | 23 ++++---- server/clearfolio.mjs | 45 ++++++++++++---- .../clearfolio-adapter-mock-hmac.test.mjs | 52 +++++++++++++++++++ tests/unit/clearfolio-status-signal.test.mjs | 29 +++++++++-- 7 files changed, 140 insertions(+), 27 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3006c74b..894b6afd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,3 +43,9 @@ contain. - Kubernetes/IaC security coverage remains a follow-up design lane for any future `infra/` or container packaging surface. +- Clearfolio is an optional production capability. The in-memory adapter + exists only under `SCOPEWEAVE_DEV=1` with no provider URL. Production + conversion fails closed when unconfigured, refuses provider redirects + that would replay tenant HMAC headers, and accepts artifact links only + from the configured Clearfolio origin until a reviewed host allowlist + exists. See `docs/doctoring/clearfolio-production-configuration.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 922a4903..4f332528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Confined the in-memory Clearfolio adapter to explicit development mode, 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. + URL components, refused provider HTTP redirects so tenant HMAC headers + cannot follow a `Location`, and rejected cross-origin, credentialed, or + fragmented artifact links instead of transplanting tokens or issuing an + unreviewed attachment-view 302. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden diff --git a/docs/deploy.md b/docs/deploy.md index 83a099b3..4b9f6178 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -53,8 +53,10 @@ Outside explicit `SCOPEWEAVE_DEV=1`, Clearfolio operations fail closed with a stable configuration error and the mock artifact route is not registered. Other ScopeWeave planning capabilities remain available. For local integration work, `SCOPEWEAVE_DEV=1` permits the in-memory adapter when the URL is absent and also -permits HTTP only for `localhost`, `127.0.0.1`, or `::1`; remote HTTP endpoints -are rejected. +permits HTTP only for `localhost`, `127.0.0.1`, or `[::1]`; remote HTTP endpoints +are rejected. Provider `fetch` calls fail closed on HTTP redirects so tenant HMAC +headers are never replayed onto another host. Attachment-view links must share +the configured Clearfolio origin until a reviewed artifact-host allowlist exists. Provider URLs are treated as service origins, not arbitrary request prefixes. Keep credentials in the dedicated HMAC secret setting rather than URL userinfo, diff --git a/docs/doctoring/clearfolio-production-configuration.md b/docs/doctoring/clearfolio-production-configuration.md index 2f1b1501..bab21c47 100644 --- a/docs/doctoring/clearfolio-production-configuration.md +++ b/docs/doctoring/clearfolio-production-configuration.md @@ -4,15 +4,19 @@ ScopeWeave treats Clearfolio as an optional production capability, not as an implicit successful mock. The in-memory converter is available only when `SCOPEWEAVE_DEV=1` and no provider URL is configured. Outside that explicit development boundary, an absent provider produces the stable `clearfolio_not_configured` failure and the mock artifact route is not registered. -A configured production provider must be a root HTTPS origin. ScopeWeave parses the operator value with the platform `URL` implementation and rejects URL credentials, query strings, fragments, and configured paths before building any downstream endpoint. HTTP is limited to explicit development mode on `localhost`, `127.0.0.1`, or `::1`. The tenant-claim HMAC secret is mandatory with a configured provider and must contain at least 32 non-whitespace characters. +A configured production provider must be a root HTTPS origin. ScopeWeave parses the operator value with the platform `URL` implementation and rejects URL credentials, query strings, fragments, and configured paths before building any downstream endpoint. HTTP is limited to explicit development mode on `localhost`, `127.0.0.1`, or `[::1]`. The tenant-claim HMAC secret is mandatory with a configured provider and must contain at least 32 non-whitespace characters. Surrounding whitespace, including a trailing newline from a secret file, is trimmed before length checking and signing so the adapter and Clearfolio verify the same key. This boundary prevents configuration text from becoming an arbitrary downstream request prefix and prevents a production deployment from persisting fake `SUCCEEDED` conversion state merely because an integration is absent. It also preserves independent ScopeWeave operation: planning functionality remains available while document conversion/viewing fails closed with an actionable configuration error. ## Artifact-token origin rule -If Clearfolio returns an `artifactToken`, ScopeWeave rewrites it into the trusted Clearfolio viewer route only when the returned URL has the same origin as the configured Clearfolio service. A token-bearing link from another origin is rejected rather than transplanted into the trusted viewer or returned directly to an unreviewed host. This closes the token-confusion boundary without claiming that arbitrary cross-origin artifact hosts are approved. +Returned artifact links must share the configured Clearfolio origin. Same-origin `artifactToken` values are rewritten into the trusted Clearfolio viewer route. A cross-origin link — with or without a token — is rejected rather than transplanted into the trusted viewer or returned through the attachment-view 302. Credentials and fragments on the returned link are also rejected. This fail-closed default does not invent a CDN allowlist; operators who later need object-storage hosts must land an explicit reviewed allowlist in a later #489 slice. -Issue #489 remains open after this slice. A subsequent bounded change must still implement the explicit reviewed artifact-origin allowlist, redirect policy, streaming response-size/media-type limits, provider-wide request budget, and the remaining resource/lifecycle acceptance criteria before the Clearfolio adapter can be described as fully production-complete. +## Provider redirect rule + +Clearfolio submit, status, and artifact-link `fetch` calls set `redirect: 'error'`. The Fetch default follows redirects and would replay tenant HMAC headers onto a `Location` host because those headers are not forbidden. A 3xx from the pinned origin therefore fails closed with the existing sanitized transport error instead of leaking tenant, subject, permissions, issued-at, or the signature. + +Issue #489 remains open after this slice. A subsequent bounded change must still implement the explicit reviewed artifact-origin allowlist, streaming response-size/media-type limits, provider-wide request budget, and the remaining resource/lifecycle acceptance criteria before the Clearfolio adapter can be described as fully production-complete. ## Executable evidence @@ -20,11 +24,12 @@ Issue #489 remains open after this slice. A subsequent bounded change must still - production without `CLEARFOLIO_URL` does not enable the mock and fails submit/status/artifact operations closed; - the mock works only under explicit `SCOPEWEAVE_DEV=1`; -- unsupported schemes, remote HTTP, URL credentials, query strings, fragments, configured paths, and weak HMAC secrets are rejected; -- loopback HTTP is accepted only under explicit development mode; and -- signed tenant headers retain the documented canonical HMAC contract. +- unsupported schemes, remote HTTP (including under `SCOPEWEAVE_DEV=1`), URL credentials, query strings, fragments, configured paths, and weak HMAC secrets are rejected; +- loopback HTTP is accepted only under explicit development mode; +- a secret-file trailing newline is trimmed before HMAC signing; and +- signed tenant headers retain the documented canonical HMAC contract and set `redirect: 'error'`. -`tests/unit/clearfolio-status-signal.test.mjs` continues to exercise sanitized transport/HTTP/JSON/status/artifact failures and now proves that a cross-origin token-bearing artifact link fails closed rather than moving the token into the Clearfolio viewer or returning it to an unreviewed host. `tests/api/attachment-status.test.mjs` makes its test-only in-memory provider explicit instead of relying on an unset production URL. +`tests/unit/clearfolio-status-signal.test.mjs` continues to exercise sanitized transport/HTTP/JSON/status/artifact failures and now proves that a redirect `TypeError`, a token-bearing or token-free cross-origin artifact link, a protocol-relative host, and credentialed or fragmented same-origin links fail closed rather than moving a token into the Clearfolio viewer or returning an unreviewed 302 target. `tests/api/attachment-status.test.mjs` makes its test-only in-memory provider explicit instead of relying on an unset production URL. The shipped `server/clearfolio.mjs` remains in the canonical c8 production coverage target, so the new configuration branches execute under the repository coverage gate rather than a documentation-only path. @@ -32,13 +37,13 @@ The shipped `server/clearfolio.mjs` remains in the canonical c8 production cover The WHATWG URL Standard defines URL components, including credentials, queries, and fragments, and provides the common parsing model used by the JavaScript `URL` API. ScopeWeave parses first and then applies component-level policy instead of relying on string-prefix validation. -OWASP's SSRF Prevention guidance recommends strict allowlisting and warns that redirects and attacker-controlled complete URLs can bypass URL validation. This slice narrows operator configuration to a provider origin and keeps request paths adapter-owned. The remaining redirect and artifact-host controls stay explicitly tracked by issue #489 rather than being implied by this narrower change. +OWASP's SSRF Prevention guidance recommends strict allowlisting and warns that redirects and attacker-controlled complete URLs can bypass URL validation. This slice narrows operator configuration to a provider origin, keeps request paths adapter-owned, refuses to follow provider redirects, and refuses unreviewed artifact hosts. The remaining artifact-host allowlist, streaming body limits, and request-budget controls stay explicitly tracked by issue #489. NIST SSDF 1.1 recommends identifying and maintaining software security requirements and producing well-secured software through repeatable verification. The fail-closed configuration contract, executable negative tests, and explicit remaining-gap statement provide acquisition-review evidence without claiming certification. ## Rollback -Rollback reverts the Clearfolio configuration parser, explicit development-mode tests, token-origin rule, deployment text, this doctoring record, and the corresponding CHANGELOG entry together. No database schema or persisted attachment representation changes in this slice. +Rollback reverts the Clearfolio configuration parser, explicit development-mode tests, same-origin artifact rule, `redirect: 'error'` provider fetches, HMAC secret trimming, deployment text, this doctoring record, and the corresponding CHANGELOG entry together. No database schema or persisted attachment representation changes in this slice. After rollback, production again follows provider redirects with tenant HMAC headers and can 302 a browser to an unreviewed artifact host. ## References diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index cb2b5f4b..1552c5b6 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -4,12 +4,13 @@ import { createHmac } from 'node:crypto'; const CF_URL_INPUT = String(process.env.CLEARFOLIO_URL || '').trim(); -const CF_SECRET = String(process.env.CLEARFOLIO_HMAC_SECRET || ''); +const CF_SECRET = String(process.env.CLEARFOLIO_HMAC_SECRET || '').trim(); const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; const CLEARFOLIO_JOB_STATUSES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); const MIN_HMAC_SECRET_LENGTH = 32; // WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`). const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); +const CLEARFOLIO_FETCH_REDIRECT = 'error'; /** Whether the process uses the explicit in-memory Clearfolio development adapter. */ export const clearfolioMock = process.env.SCOPEWEAVE_DEV === '1' && !CF_URL_INPUT; @@ -129,6 +130,22 @@ export function signClaims(tenantId, subjectId, permissions, issuedAt, secret) { return createHmac('sha256', secret).update(payload).digest('base64url'); } +/** + * Call a Clearfolio endpoint without following redirects. + * + * The Fetch default `redirect: 'follow'` would replay tenant HMAC headers + * onto a 3xx Location. Those headers are not forbidden, so a pinned origin + * that redirects would leak tenant, subject, permissions, issued-at, and + * the signature to another host. Fail closed instead. + * + * @param {string} url - Adapter-constructed Clearfolio endpoint. + * @param {RequestInit} [options] - Method, headers, body, and optional signal. + * @returns {Promise} Downstream response that did not redirect. + */ +function fetchClearfolio(url, options = {}) { + return fetch(url, { ...options, redirect: CLEARFOLIO_FETCH_REDIRECT }); +} + /** * Build tenant-scoped Clearfolio request headers without exposing credentials. * @@ -217,7 +234,7 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { form.append('file', new Blob([bytes], { type: mime || 'application/octet-stream' }), name); let res; try { - res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs`, { + res = await fetchClearfolio(`${configuration.baseUrl}/api/v1/convert/jobs`, { method: 'POST', headers: tenantHeaders(orgId, userId, configuration.secret), body: form, @@ -259,7 +276,7 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { if (configuration.mock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; let res; try { - res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + res = await fetchClearfolio(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { headers: tenantHeaders(orgId, userId, configuration.secret), signal, }); @@ -277,10 +294,13 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { /** * Issue a viewable artifact URL for a completed Clearfolio job. * - * Same-origin `artifactToken` values may be translated into the trusted viewer - * route. Token-bearing links from another origin are rejected until an explicit - * reviewed artifact-origin allowlist exists; tokens are never transplanted or - * returned to an unreviewed cross-origin host. + * Artifact links must share the configured Clearfolio origin. Same-origin + * `artifactToken` values may be translated into the trusted viewer route. + * Cross-origin links — with or without a token — are rejected until an + * explicit reviewed artifact-origin allowlist exists. Tokens are never + * transplanted or returned to an unreviewed host. Credentials and fragments + * on the returned link are rejected so the viewer redirect cannot carry + * provider userinfo or fragment-only confusion. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -293,7 +313,7 @@ export async function artifactUrl(orgId, userId, jobId) { if (configuration.mock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; let res; try { - res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { + res = await fetchClearfolio(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { method: 'POST', headers: tenantHeaders(orgId, userId, configuration.secret), }); @@ -320,12 +340,15 @@ export async function artifactUrl(orgId, userId, jobId) { if (url.protocol !== 'https:' && !allowsHttp) { throw new Error('clearfolio artifact-link response invalid'); } + if (url.username || url.password || url.hash) { + throw new Error('clearfolio artifact-link response invalid'); + } + if (url.origin !== clearfolioUrl.origin) { + throw new Error('clearfolio artifact-link response invalid'); + } const token = url.searchParams.get('artifactToken'); if (token) { - if (url.origin !== clearfolioUrl.origin) { - throw new Error('clearfolio artifact-link response invalid'); - } return `${configuration.baseUrl}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; } return url.href; diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index fb48b570..24b831b6 100644 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs @@ -104,6 +104,14 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', 'RUNNING', 'explicit development mode accepts the IPv6 loopback origin documented by the adapter', ); + + process.env.CLEARFOLIO_URL = 'http://clearfolio.example'; + const remoteDev = await freshModule('development-remote-http'); + await assert.rejects( + () => remoteDev.jobStatus(1, 2, 'job-1'), + (error) => error.code === 'clearfolio_transport_insecure', + 'explicit development mode still rejects remote HTTP provider origins', + ); } finally { globalThis.fetch = originalFetch; delete process.env.SCOPEWEAVE_DEV; @@ -112,6 +120,45 @@ test('production URL and HMAC configuration rejects ambiguous or unsafe input', } }); +test('HMAC secrets trim surrounding whitespace before signing', async () => { + delete process.env.SCOPEWEAVE_DEV; + process.env.CLEARFOLIO_URL = 'https://clearfolio.example/'; + process.env.CLEARFOLIO_HMAC_SECRET = `${HMAC_SECRET}\n`; + const originalFetch = globalThis.fetch; + const originalNow = Date.now; + let observedOptions; + Date.now = () => 1_750_000_000_000; + globalThis.fetch = async (_url, options) => { + observedOptions = options; + return { + ok: true, + status: 200, + json: async () => ({ status: 'RUNNING' }), + }; + }; + + try { + const signed = await freshModule('hmac-secret-file-newline'); + assert.equal(await signed.jobStatus(21, 34, 'signed-job'), 'RUNNING'); + assert.equal( + observedOptions.headers['X-Clearfolio-Claims-Signature'], + signed.signClaims( + 'sw-org-21', + 'sw-user-34', + 'job:create,job:read,viewer:read,artifact-link:create', + '1750000000', + HMAC_SECRET, + ), + 'a trailing newline from a secret file must not change the signed HMAC', + ); + } finally { + Date.now = originalNow; + globalThis.fetch = originalFetch; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; + } +}); + test('Clearfolio tenant claim headers use the documented HMAC contract', async () => { delete process.env.SCOPEWEAVE_DEV; process.env.CLEARFOLIO_URL = 'https://clearfolio.example/'; @@ -162,6 +209,11 @@ test('Clearfolio tenant claim headers use the documented HMAC contract', async ( observedOptions.headers['X-Clearfolio-Claims-Signature'], /=/, ); + assert.equal( + observedOptions.redirect, + 'error', + 'tenant HMAC headers must not follow a 3xx Location to another host', + ); } finally { Date.now = originalNow; globalThis.fetch = originalFetch; diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index 0f30e231..f30a6e8c 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -53,6 +53,18 @@ test('jobStatus enforces endpoint, signal, transport, HTTP, and status contracts assert.equal(status, 'RUNNING'); assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs/job-1'); assert.equal(observedOptions.signal, controller.signal); + assert.equal( + observedOptions.redirect, + 'error', + 'status lookups must not follow redirects that would replay tenant HMAC headers', + ); + + setNetworkError(new TypeError('Failed to fetch: redirect')); + await expectSanitizedFailure( + () => jobStatus(1, 2, 'job-1'), + 'clearfolio status unavailable', + /Failed to fetch|redirect/, + ); setNetworkError(new Error('connect ECONNREFUSED https://private-clearfolio.internal')); await expectSanitizedFailure( @@ -124,6 +136,7 @@ test('submitJob rejects transport details and malformed successful responses', a ); assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs'); assert.equal(observedOptions.method, 'POST'); + assert.equal(observedOptions.redirect, 'error'); assert.ok(observedOptions.body instanceof FormData); const malformedPayloads = [ @@ -189,6 +202,11 @@ test('artifactUrl validates links and never exposes transport or response text', 'https://clearfolio.example/api/v1/viewer/job-1/artifact-links', ); assert.equal(observedOptions.method, 'POST'); + assert.equal( + observedOptions.redirect, + 'error', + 'artifact-link requests must not follow redirects that would replay tenant HMAC headers', + ); const malformedPayloads = [ { @@ -204,6 +222,10 @@ test('artifactUrl validates links and never exposes transport or response text', { label: 'malformed URL', json: async () => ({ artifactUrl: 'http://[' }) }, { label: 'unsupported URL scheme', json: async () => ({ artifactUrl: 'javascript:alert(1)' }) }, { label: 'HTTPS downgrade', json: async () => ({ artifactUrl: 'http://cdn.example/file.pdf' }) }, + { label: 'cross-origin host', json: async () => ({ url: 'https://cdn.example/file.pdf' }) }, + { label: 'protocol-relative host', json: async () => ({ artifactUrl: '//cdn.example/file.pdf' }) }, + { label: 'credentialed link', json: async () => ({ artifactUrl: 'https://user:pass@clearfolio.example/file.pdf' }) }, + { label: 'fragmented link', json: async () => ({ artifactUrl: 'https://clearfolio.example/file.pdf#token' }) }, ]; for (const malformed of malformedPayloads) { @@ -231,9 +253,10 @@ test('artifactUrl validates links and never exposes transport or response text', ); setResponse({ json: async () => ({ url: 'https://cdn.example/file.pdf' }) }); - assert.equal( - await artifactUrl(4, 5, 'job-1'), - 'https://cdn.example/file.pdf', + await expectSanitizedFailure( + () => artifactUrl(4, 5, 'job-1'), + 'clearfolio artifact-link response invalid', + /cdn\.example/, ); setResponse({