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 bc41a659..4f332528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - 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, 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 0cfdb799..4b9f6178 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,18 +34,35 @@ 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`, 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`). | -| `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). | -| `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). | +| `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. | | `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 `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, +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 diff --git a/docs/doctoring/clearfolio-production-configuration.md b/docs/doctoring/clearfolio-production-configuration.md new file mode 100644 index 00000000..bab21c47 --- /dev/null +++ b/docs/doctoring/clearfolio-production-configuration.md @@ -0,0 +1,54 @@ +# 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. 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 + +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. + +## 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 + +`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 (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 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. + +## 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, 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, 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 + +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/ diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 8933e961..1552c5b6 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -1,15 +1,116 @@ // 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 || '').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 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.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.`, + ); + } + + return { + mock: false, + baseUrl: url.origin, + secret: CF_SECRET, + }; +} /** * Sign tenant claims using the Clearfolio HMAC interoperability contract. @@ -29,33 +130,47 @@ 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. * * @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 +199,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 +224,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 +234,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 fetchClearfolio(`${configuration.baseUrl}/api/v1/convert/jobs`, { method: 'POST', - headers: tenantHeaders(orgId, userId), + headers: tenantHeaders(orgId, userId, configuration.secret), body: form, }); } catch { @@ -156,11 +272,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 fetchClearfolio(`${configuration.baseUrl}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId, configuration.secret), signal, }); } catch { @@ -177,10 +294,13 @@ 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. + * 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. @@ -189,12 +309,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 fetchClearfolio(`${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 +331,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'); @@ -219,12 +340,16 @@ 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'); + } - // PDF.js 뷰어 페이지 우선(clearfolio external artifactToken 모드): 토큰을 - // 추출해 /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; } 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'; diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs index 85ca5894..24b831b6 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 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 import('../../server/clearfolio.mjs?mock-adapter-contract-test=1'); + const mock = await freshModule('mock-adapter-contract'); assert.equal(mock.clearfolioMock, true); assert.equal(mock.mockArtifact('missing-job'), null); @@ -28,11 +52,117 @@ 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 = [ + ['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'], + ['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'], + [ + 'https://clearfolio.example', + `${'a'.repeat(16)}${' '.repeat(40)}${'b'.repeat(15)}`, + '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'); + + 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', + ); + + 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; + delete process.env.CLEARFOLIO_URL; + delete process.env.CLEARFOLIO_HMAC_SECRET; + } +}); + +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/'; - 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 +179,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,13 +202,18 @@ 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( 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 cf3ad02c..f30a6e8c 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 () => { @@ -49,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( @@ -120,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 = [ @@ -185,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 = [ { @@ -200,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) { @@ -217,10 +243,20 @@ test('artifactUrl validates links and never exposes transport or response text', 'https://clearfolio.example/signed/file.pdf', ); - setResponse({ json: async () => ({ url: 'https://cdn.example/file.pdf' }) }); + setResponse({ json: async () => ({ + signedUrl: 'https://clearfolio.example/file.pdf?artifactToken=same%20origin', + }) }); assert.equal( await artifactUrl(4, 5, 'job-1'), - 'https://cdn.example/file.pdf', + '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' }) }); + await expectSanitizedFailure( + () => artifactUrl(4, 5, 'job-1'), + 'clearfolio artifact-link response invalid', + /cdn\.example/, ); setResponse({ @@ -228,24 +264,27 @@ 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://clearfolio.example/viewer/job-1?artifactToken=token%20value', + await expectSanitizedFailure( + () => artifactUrl(4, 5, 'job-1'), + 'clearfolio artifact-link response invalid', ); }); -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; } });