diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..29060f8d 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, 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 diff --git a/docs/deploy.md b/docs/deploy.md index 0cfdb799..83a099b3 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`, 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 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..764a9f7e --- /dev/null +++ b/docs/doctoring/clearfolio-production-configuration.md @@ -0,0 +1,51 @@ +# 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. + +## Provider redirect and artifact-origin rule + +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. + +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 + +`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` 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. + +## 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, 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, 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 + +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/ \ No newline at end of file diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 8933e961..8ba55a90 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -1,15 +1,115 @@ // 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; +// 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 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. @@ -34,28 +134,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,23 +182,25 @@ 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. + * 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. @@ -109,7 +209,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,10 +219,11 @@ 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, + redirect: 'error', }); } catch { throw new Error('clearfolio submit unavailable'); @@ -147,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. @@ -156,12 +260,14 @@ 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, + redirect: 'error', }); } catch { throw new Error('clearfolio status unavailable'); @@ -177,10 +283,12 @@ 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. + * 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. @@ -189,12 +297,14 @@ 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), + redirect: 'error', }); } catch { throw new Error('clearfolio artifact-link unavailable'); @@ -210,7 +320,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 +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'); + } - // 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; -} +} \ No newline at end of file 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..fb48b570 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,70 @@ 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', + ); + } 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 +132,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 +155,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( diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs index cf3ad02c..1ed3b643 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,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( @@ -120,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 = [ @@ -185,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 = [ { @@ -200,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) { @@ -217,10 +228,13 @@ 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({ @@ -228,24 +242,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; } -}); +}); \ No newline at end of file