From 338b3cc91648a90cb6b58ce37f48cc364a3f269d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 23:17:48 +0900 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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 b8ebe96fbb7254638eab372ad4ee09bac78fa637 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:18:38 +0900 Subject: [PATCH 20/24] test(clearfolio): fail closed on redirects and foreign artifact links --- tests/unit/clearfolio-status-signal.test.mjs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index 0f30e231..1ed3b643 100644 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ b/tests/unit/clearfolio-status-signal.test.mjs @@ -53,6 +53,7 @@ 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'); setNetworkError(new Error('connect ECONNREFUSED https://private-clearfolio.internal')); await expectSanitizedFailure( @@ -124,6 +125,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 +191,7 @@ 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'); const malformedPayloads = [ { @@ -204,6 +207,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: 'foreign HTTPS origin', json: async () => ({ artifactUrl: 'https://cdn.example/file.pdf' }) }, + { label: 'protocol-relative foreign origin', json: async () => ({ artifactUrl: '//evil.example/file.pdf' }) }, + { label: 'credentialed same origin', json: async () => ({ artifactUrl: 'https://user@clearfolio.example/file.pdf' }) }, + { label: 'fragmented same origin', json: async () => ({ artifactUrl: 'https://clearfolio.example/file.pdf#viewer-state' }) }, ]; for (const malformed of malformedPayloads) { @@ -230,12 +237,6 @@ 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', - ); - setResponse({ json: async () => ({ signedUrl: 'https://cdn.example/file.pdf?artifactToken=token%20value', @@ -264,4 +265,4 @@ test('artifactUrl permits HTTP only for explicit loopback development', async () process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; delete process.env.SCOPEWEAVE_DEV; } -}); +}); \ No newline at end of file From 32e44e4820eba722e59febe84c7f37d7a20930b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:19:57 +0900 Subject: [PATCH 21/24] fix(clearfolio): reject redirect replay and foreign artifact links --- server/clearfolio.mjs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index cb2b5f4b..8ba55a90 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -199,6 +199,8 @@ export const mockArtifact = (jobId) => (clearfolioMock ? mockDocs.get(jobId) || * * Downstream response text and transport errors are never copied into the * thrown error because the caller may serialize that message to a browser. + * Redirect following is disabled so tenant HMAC headers are never replayed to + * an untrusted Location target. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -221,6 +223,7 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { method: 'POST', headers: tenantHeaders(orgId, userId, configuration.secret), body: form, + redirect: 'error', }); } catch { throw new Error('clearfolio submit unavailable'); @@ -246,6 +249,8 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { * without an exact documented conversion state all throw fixed operation-level * errors. The bounded refresh engine can therefore preserve the previously * persisted state without logging or returning private downstream details. + * Redirect following is disabled so tenant HMAC headers stay bound to the + * configured provider origin. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting user identifier. @@ -262,6 +267,7 @@ export async function jobStatus(orgId, userId, jobId, { signal } = {}) { res = await fetch(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { headers: tenantHeaders(orgId, userId, configuration.secret), signal, + redirect: 'error', }); } catch { throw new Error('clearfolio status unavailable'); @@ -277,10 +283,12 @@ 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. + * This root production-config slice accepts only the configured provider origin. + * Cross-origin artifact hosts remain fail-closed until an explicit reviewed + * allowlist lands. Credentials and fragments are never accepted as browser + * redirect authority. Same-origin `artifactToken` values may be translated into + * the trusted viewer route, and redirect following is disabled for the provider + * request so tenant HMAC headers cannot be replayed to a Location target. * * @param {string|number} orgId - ScopeWeave organization identifier. * @param {string|number} userId - Requesting ScopeWeave user identifier. @@ -296,6 +304,7 @@ export async function artifactUrl(orgId, userId, jobId) { res = await fetch(`${configuration.baseUrl}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { method: 'POST', headers: tenantHeaders(orgId, userId, configuration.secret), + redirect: 'error', }); } catch { throw new Error('clearfolio artifact-link unavailable'); @@ -320,13 +329,18 @@ export async function artifactUrl(orgId, userId, jobId) { if (url.protocol !== 'https:' && !allowsHttp) { throw new Error('clearfolio artifact-link response invalid'); } + if ( + url.origin !== clearfolioUrl.origin + || url.username + || url.password + || url.hash + ) { + 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; -} +} \ No newline at end of file From 95b18d71ecf12b48e93b033c93d8dfe406fac219 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:22:02 +0900 Subject: [PATCH 22/24] docs(clearfolio): record redirect and origin trust boundary --- .../clearfolio-production-configuration.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/clearfolio-production-configuration.md b/docs/doctoring/clearfolio-production-configuration.md index 2f1b1501..764a9f7e 100644 --- a/docs/doctoring/clearfolio-production-configuration.md +++ b/docs/doctoring/clearfolio-production-configuration.md @@ -8,11 +8,13 @@ A configured production provider must be a root HTTPS origin. ScopeWeave parses 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 +## Provider redirect and artifact-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. +Every tenant-signed submit, status, and artifact-link fetch uses `redirect: "error"`. A provider redirect therefore becomes the existing sanitized transport failure instead of allowing the runtime to replay tenant HMAC headers onto an untrusted `Location` target. -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. +Artifact links returned by this root configuration slice must resolve to the configured Clearfolio origin, must contain no URL credentials, and must contain no fragment. Protocol-relative or absolute foreign-host links fail closed, including token-free links that would otherwise become the browser's attachment-view redirect target. If a same-origin link contains an `artifactToken`, ScopeWeave rewrites that token into the trusted Clearfolio viewer route. A token is never transplanted into another origin. + +This root slice deliberately does not invent a cross-origin artifact-host allowlist. A later reviewed policy may admit explicitly configured canonical origins, but until that policy is present the secure default is same-origin only. Issue #489 remains open after this slice. Subsequent bounded work must still add the reviewed artifact-origin allowlist if cross-origin delivery is required, streaming response-size/media-type limits, a provider-wide request budget, and the remaining resource/lifecycle acceptance criteria before the Clearfolio adapter can be described as fully production-complete. ## Executable evidence @@ -24,7 +26,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 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` exercises sanitized transport/HTTP/JSON/status/artifact failures and now requires all three tenant-signed fetch paths to disable redirects. It rejects token-free CDN links, protocol-relative foreign links, credential-bearing same-origin links, fragmented same-origin links, and cross-origin token-bearing links while retaining same-origin relative links and the trusted viewer rewrite. `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 +34,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, disables redirect following for tenant-signed calls, and keeps browser redirect authority same-origin until an explicit reviewed allowlist exists. 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, redirect prohibition, same-origin artifact rule, deployment text, this doctoring record, and the corresponding CHANGELOG entry together. No database schema or persisted attachment representation changes in this slice. ## References @@ -46,4 +48,4 @@ National Institute of Standards and Technology. (2022). *Secure Software Develop 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/ +WHATWG. (2026). *URL Standard*. https://url.spec.whatwg.org/ \ No newline at end of file From d16d80bc9f17c9011b11d4cb32eb222307ff0157 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:22:18 +0900 Subject: [PATCH 23/24] docs(changelog): record Clearfolio redirect hardening --- CHANGELOG.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 922a4903..2b1683f0 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, disabled redirect following on tenant-signed provider calls, + and rejected cross-origin, credential-bearing, or fragmented artifact links + until an explicit reviewed artifact-origin allowlist is configured by a later + slice. - 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 @@ -87,11 +89,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial ScopeWeave Planner release with tree-table editing, cumulative metrics, CSV import/export, and Gantt modal. -- `wbs.json` seed loading plus browser autosave and optional file sync. -- Playwright E2E coverage for add/edit hierarchy flows, delete - confirmation, subtree drag-and-drop, and JSON sync shape. -- GitHub Pages deployment workflow and operator documentation. - -## [1.0.1] - 2026-06-25 -### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- `wbs.json` seed loading plus browser autosave and optional file sync. \ No newline at end of file From f4432f40a427ba52beaa754348f6cb38d0317c6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:49:52 +0900 Subject: [PATCH 24/24] fix(changelog): preserve published release notes --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1683f0..b7bd009a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,4 +89,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial ScopeWeave Planner release with tree-table editing, cumulative metrics, CSV import/export, and Gantt modal. -- `wbs.json` seed loading plus browser autosave and optional file sync. \ No newline at end of file +- `wbs.json` seed loading plus browser autosave and optional file sync. +- Playwright E2E coverage for add/edit hierarchy flows, delete + confirmation, subtree drag-and-drop, and JSON sync shape. +- GitHub Pages deployment workflow and operator documentation. + +## [1.0.1] - 2026-06-25 +### 성능 개선 (Performance) +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다.