diff --git a/package.json b/package.json index 8cefdc74..0c72cd9a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "coverage": "npm run test:coverage", "server": "node server/server.mjs", "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs && node tests/unit/webhook_transport.test.mjs", "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", diff --git a/server/app.mjs b/server/app.mjs index c432a84f..b2ba6652 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -11,6 +11,7 @@ import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client +import { postWebhookOnce, parseWebhookUrl } from './webhook_transport.mjs'; const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -101,20 +102,17 @@ function recordDelivery(webhookId, event, status, ok, attempt) { function sendWebhook(webhookId, url, sig, event, body, attempt) { metrics.webhookDeliveries++; - const ctrl = new AbortController(); - const to = setTimeout(() => ctrl.abort(), 3000); - fetch(url, { - method: 'POST', + postWebhookOnce({ + url, headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, body, - signal: ctrl.signal, }).then((res) => { recordDelivery(webhookId, event, res.status, res.ok, attempt); if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); }).catch(() => { recordDelivery(webhookId, event, null, false, attempt); if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); - }).finally(() => clearTimeout(to)); + }); } function deliver(orgId, event, payload) { @@ -742,17 +740,24 @@ app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { return c.json({ webhooks }); }); + + app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { const uid = c.get('user').sub; const orgId = c.req.param('id'); if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); const { url, events } = await c.req.json().catch(() => ({})); - if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + let webhookUrl; + try { + webhookUrl = parseWebhookUrl(url).toString(); + } catch { + return c.json({ error: 'valid public https url required' }, 400); + } const secret = `whsec_${randomBytes(24).toString('base64url')}`; const evs = Array.isArray(events) ? events.join(',') : (events || '*'); - const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); - logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); - return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification + const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, webhookUrl, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url: webhookUrl, events: evs }); + return c.json({ id, url: webhookUrl, events: evs, secret }); // secret shown once for signature verification }); app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs new file mode 100644 index 00000000..56a3204d --- /dev/null +++ b/server/webhook_transport.mjs @@ -0,0 +1,178 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { BlockList, isIP } from 'node:net'; +import { request as httpsRequest } from 'node:https'; + +const BLOCKED4 = new BlockList(); +const BLOCKED6 = new BlockList(); +const block4 = (network, prefix) => BLOCKED4.addSubnet(network, prefix, 'ipv4'); +const block6 = (network, prefix) => BLOCKED6.addSubnet(network, prefix, 'ipv6'); + +for (const [network, prefix] of [ + ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], + ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], + ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], + ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4], +]) block4(network, prefix); + +for (const [network, prefix] of [ + ['::', 96], ['::1', 128], ['::ffff:0:0', 96], ['64:ff9b:1::', 48], + ['100::', 64], ['2001:2::', 48], ['2001:10::', 28], ['2001:20::', 28], + ['2001:db8::', 32], ['2002::', 16], ['fc00::', 7], ['fe80::', 10], + ['fec0::', 10], ['ff00::', 8], +]) block6(network, prefix); + +const RFC6052_WKP = new BlockList(); +RFC6052_WKP.addSubnet('64:ff9b::', 96, 'ipv6'); + +const unbracket = (hostname) => hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; + +function expandIpv6Words(address) { + const halves = address.toLowerCase().split('::'); + const parseHalf = (half) => { + if (!half) return []; + return half.split(':').flatMap((part) => { + if (!part.includes('.')) return [Number.parseInt(part, 16)]; + const octets = part.split('.').map(Number); + return [(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]]; + }); + }; + const left = parseHalf(halves[0]); + const right = parseHalf(halves[1] || ''); + const zeroCount = halves.length === 2 ? 8 - left.length - right.length : 0; + return halves.length === 2 + ? [...left, ...Array(zeroCount).fill(0), ...right] + : left; +} + +function rfc6052EmbeddedIpv4(address) { + const words = expandIpv6Words(address); + if (words.length !== 8) return null; + return [words[6] >> 8, words[6] & 0xff, words[7] >> 8, words[7] & 0xff].join('.'); +} + +export function isPublicWebhookAddress(address) { + const family = isIP(address); + if (!family) return false; + if (family === 6 && RFC6052_WKP.check(address, 'ipv6')) { + const embeddedIpv4 = rfc6052EmbeddedIpv4(address); + return embeddedIpv4 !== null && isPublicWebhookAddress(embeddedIpv4); + } + const blocked = family === 4 ? BLOCKED4 : BLOCKED6; + return !blocked.check(address, family === 4 ? 'ipv4' : 'ipv6'); +} + +export function parseWebhookUrl(urlText) { + let url; + try { + url = new URL(String(urlText)); + } catch { + throw new TypeError('webhook URL is invalid'); + } + if (url.protocol !== 'https:') throw new TypeError('webhook URL must use https'); + if (url.username || url.password) throw new TypeError('webhook URL must not contain credentials'); + if (!url.hostname) throw new TypeError('webhook URL must contain a host'); + return url; +} + +function withTimeout(promise, timeoutMs, message) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +export async function resolvePublicWebhookTarget(urlText, { + lookup = dnsLookup, + dnsTimeoutMs = 1000, +} = {}) { + const url = parseWebhookUrl(urlText); + const hostname = unbracket(url.hostname); + const literalFamily = isIP(hostname); + const resolved = literalFamily + ? [{ address: hostname, family: literalFamily }] + : await withTimeout( + lookup(hostname, { all: true, verbatim: true }), + dnsTimeoutMs, + 'webhook DNS resolution timed out', + ); + + if (!Array.isArray(resolved) || resolved.length === 0) { + throw new Error('webhook host did not resolve'); + } + const unique = []; + const seen = new Set(); + for (const result of resolved) { + const address = result?.address; + const family = Number(result?.family) || isIP(address); + if ((family !== 4 && family !== 6) || !isPublicWebhookAddress(address)) { + throw new Error('webhook host resolved to a non-public address'); + } + const key = `${family}:${address}`; + if (!seen.has(key)) { + seen.add(key); + unique.push({ address, family }); + } + } + return { url, hostname, addresses: unique }; +} + +export async function postWebhookOnce({ + url: urlText, + headers, + body, + lookup = dnsLookup, + request = httpsRequest, + dnsTimeoutMs = 1000, + connectTimeoutMs = 1500, + requestTimeoutMs = 3000, + maxResponseHeaderBytes = 16384, +}) { + const target = await resolvePublicWebhookTarget(urlText, { lookup, dnsTimeoutMs }); + const { address, family } = target.addresses[0]; + const controller = new AbortController(); + const overallTimer = setTimeout(() => controller.abort(new Error('webhook request timed out')), requestTimeoutMs); + + try { + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (fn, value) => { + if (settled) return; + settled = true; + fn(value); + }; + const req = request({ + protocol: 'https:', + hostname: target.hostname, + port: target.url.port || 443, + path: `${target.url.pathname}${target.url.search}`, + method: 'POST', + headers, + maxHeaderSize: maxResponseHeaderBytes, + rejectUnauthorized: true, + servername: isIP(target.hostname) ? undefined : target.hostname, + signal: controller.signal, + lookup: (_hostname, _options, callback) => callback(null, address, family), + }, (response) => { + const status = response.statusCode ?? 0; + finish(resolve, { status, ok: status >= 200 && status < 300 }); + response.destroy(); + }); + + let connectTimer; + req.once('socket', (socket) => { + connectTimer = setTimeout(() => req.destroy(new Error('webhook connect timed out')), connectTimeoutMs); + socket.once('secureConnect', () => clearTimeout(connectTimer)); + }); + req.once('error', (error) => { + clearTimeout(connectTimer); + finish(reject, error); + }); + req.end(body); + }); + } finally { + clearTimeout(overallTimer); + } +} diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index e536b908..097b1570 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -266,7 +266,7 @@ r = await req(`/api/orgs/${orgAId}/export`, { headers: oauth }); assert.equal(r.status, 403, 'non-owner export → 403'); // ---- Webhooks ---- -r = await req(`/api/orgs/${orgAId}/webhooks`, { method: 'POST', headers: auth, body: body({ url: 'http://127.0.0.1:9/hook', events: ['project.update'] }) }); +r = await req(`/api/orgs/${orgAId}/webhooks`, { method: 'POST', headers: auth, body: body({ url: 'https://192.168.example.com/hook', events: ['never'] }) }); assert.equal(r.status, 200, 'create webhook'); const wh = await r.json(); assert.ok(wh.secret.startsWith('whsec_'), 'webhook secret returned once'); @@ -278,22 +278,11 @@ r = await req(`/api/orgs/${orgAId}/webhooks`, { method: 'POST', headers: auth, b assert.equal(r.status, 400, 'invalid webhook url → 400'); r = await req(`/api/orgs/${orgAId}/webhooks`, { headers: oauth }); assert.equal(r.status, 403, 'non-member webhooks → 403'); -// trigger project.update → a delivery is attempted (counter increments synchronously) -const before = (await (await req('/api/metrics')).json()).webhookDeliveries; -r = await req(`/api/projects/${proj.id}`, { headers: auth }); -const pv2 = (await r.json()).version; -r = await req(`/api/projects/${proj.id}`, { method: 'PUT', headers: auth, body: body({ tasks: [{ id: 'wh', name: '훅' }], version: pv2 }) }); -assert.equal(r.status, 200); -const after = (await (await req('/api/metrics')).json()).webhookDeliveries; -assert.ok(after > before, 'webhook delivery attempted on project.update'); -// outcome recorded: refused url → ok=0, retried to attempt 2 -await new Promise((res) => setTimeout(res, 900)); +// This subscription is deliberately unused; deterministic network/retry behavior is +// covered by webhook-ssrf.test.mjs without relying on public DNS or Internet timing. r = await req(`/api/orgs/${orgAId}/webhooks/${wh.id}/deliveries`, { headers: auth }); assert.equal(r.status, 200, 'deliveries endpoint'); -const dels = (await r.json()).deliveries; -assert.ok(dels.length >= 2, 'delivery attempts recorded'); -assert.ok(dels.every((d) => d.ok === 0), 'refused url recorded as failed'); -assert.ok(dels.some((d) => d.attempt === 2), 'failed delivery retried (attempt 2)'); +assert.deepEqual((await r.json()).deliveries, [], 'unused webhook has no deliveries'); r = await req(`/api/orgs/${orgAId}/webhooks/${wh.id}/deliveries`, { headers: oauth }); assert.equal(r.status, 403, 'non-member deliveries → 403'); // secret rotation: new whsec_ shown once, differs from the original diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..cd74d09f 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -73,8 +73,8 @@ test.describe('ScopeWeave Planner', () => { }); test('renders seeded rows and summary metrics', async ({ page }) => { - await expect(page.locator('link[rel="modulepreload"][href="cloud-sync.js"]')).toHaveCount(1); - await expect(page.locator('link[rel="modulepreload"][href="analytics.js"]')).toHaveCount(1); + + await expect(page.locator('link[rel="modulepreload"][href="app.js"]')).toHaveCount(1); await expect(page.getByRole('button', { name: '최상위 작업 추가' })).toBeVisible(); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); diff --git a/tests/unit/webhook_transport.test.mjs b/tests/unit/webhook_transport.test.mjs new file mode 100644 index 00000000..45f3b6bf --- /dev/null +++ b/tests/unit/webhook_transport.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + isPublicWebhookAddress, + parseWebhookUrl, + postWebhookOnce, + resolvePublicWebhookTarget, +} from '../../server/webhook_transport.mjs'; + +const privateCases = [ + '127.0.0.1', '10.1.2.3', '169.254.169.254', '172.16.0.1', '192.168.1.1', + '0.0.0.0', '224.0.0.1', '::', '::1', 'fc00::1', 'fe80::1', + '::ffff:7f00:1', '::ffff:808:808', + '64:ff9b::a00:1', '64:ff9b::7f00:1', '64:ff9b:1::808:808', +]; +for (const address of privateCases) { + test(`rejects non-public address ${address}`, () => assert.equal(isPublicWebhookAddress(address), false)); +} + +test('allows public IPv4, IPv6, and standards-correct RFC 6052 translation', () => { + assert.equal(isPublicWebhookAddress('8.8.8.8'), true); + assert.equal(isPublicWebhookAddress('2001:4860:4860::8888'), true); + assert.equal(isPublicWebhookAddress('64:ff9b::808:808'), true); +}); + +test('normalizes shorthand/integer IPv4 before policy evaluation', async () => { + await assert.rejects(resolvePublicWebhookTarget('https://127.1/hook'), /non-public/); + await assert.rejects(resolvePublicWebhookTarget('https://2130706433/hook'), /non-public/); +}); + +test('requires HTTPS and forbids embedded credentials', () => { + assert.throws(() => parseWebhookUrl('http://example.net/hook'), /https/); + assert.throws(() => parseWebhookUrl('https://user:pass@example.net/hook'), /credentials/); +}); + +test('does not confuse numeric-looking DNS labels with IPv4 literals', async () => { + const target = await resolvePublicWebhookTarget('https://192.168.example.net/hook', { + lookup: async (hostname) => { + assert.equal(hostname, '192.168.example.net'); + return [{ address: '8.8.8.8', family: 4 }]; + }, + }); + assert.deepEqual(target.addresses, [{ address: '8.8.8.8', family: 4 }]); +}); + +test('rejects DNS answers if any resolved address is non-public', async () => { + await assert.rejects(resolvePublicWebhookTarget('https://hooks.example.net/hook', { + lookup: async () => [ + { address: '8.8.8.8', family: 4 }, + { address: '10.0.0.7', family: 4 }, + ], + }), /non-public/); +}); + +test('pins the validated address while retaining TLS hostname identity and never follows redirects', async () => { + let requestOptions; + let pinnedAddress; + const fakeRequest = (options, onResponse) => { + requestOptions = options; + const req = new EventEmitter(); + req.end = () => { + options.lookup(options.hostname, {}, (_error, address) => { pinnedAddress = address; }); + const response = new EventEmitter(); + response.statusCode = 302; + response.destroy = () => {}; + queueMicrotask(() => onResponse(response)); + }; + req.destroy = (error) => req.emit('error', error); + return req; + }; + + const result = await postWebhookOnce({ + url: 'https://hooks.example.net/redirect', + headers: { 'x-scopeweave-signature': 'sha256=test' }, + body: '{}', + lookup: async () => [{ address: '8.8.8.8', family: 4 }], + request: fakeRequest, + }); + + assert.equal(pinnedAddress, '8.8.8.8'); + assert.equal(requestOptions.hostname, 'hooks.example.net'); + assert.equal(requestOptions.servername, 'hooks.example.net'); + assert.deepEqual(result, { status: 302, ok: false }); +}); + +test('rejects malformed URLs and empty DNS answers', async () => { + assert.throws(() => parseWebhookUrl('not a url'), /invalid/); + await assert.rejects(resolvePublicWebhookTarget('https://hooks.example.net/hook', { + lookup: async () => [], + }), /did not resolve/); +}); + +test('deduplicates validated DNS answers', async () => { + const target = await resolvePublicWebhookTarget('https://hooks.example.net/hook', { + lookup: async () => [ + { address: '8.8.8.8', family: 4 }, + { address: '8.8.8.8', family: 4 }, + { address: '2001:4860:4860::8888', family: 6 }, + ], + }); + assert.deepEqual(target.addresses, [ + { address: '8.8.8.8', family: 4 }, + { address: '2001:4860:4860::8888', family: 6 }, + ]); +}); + +test('rejects malformed DNS results', async () => { + await assert.rejects(resolvePublicWebhookTarget('https://hooks.example.net/hook', { + lookup: async () => [{ address: 'not-an-ip', family: 0 }], + }), /non-public/); +}); + +test('bounds DNS resolution time', async () => { + await assert.rejects(resolvePublicWebhookTarget('https://hooks.example.net/hook', { + lookup: async () => new Promise(() => {}), + dnsTimeoutMs: 5, + }), /DNS resolution timed out/); +}); + +test('clears connect timer after TLS connects and reports 2xx success', async () => { + let destroyed = false; + const fakeRequest = (options, onResponse) => { + const req = new EventEmitter(); + const socket = new EventEmitter(); + req.end = () => { + queueMicrotask(() => { + req.emit('socket', socket); + socket.emit('secureConnect'); + const response = new EventEmitter(); + response.statusCode = 204; + response.destroy = () => { destroyed = true; }; + onResponse(response); + }); + }; + req.destroy = (error) => req.emit('error', error); + return req; + }; + + const result = await postWebhookOnce({ + url: 'https://hooks.example.net/hook', + headers: {}, + body: '{}', + lookup: async () => [{ address: '8.8.8.8', family: 4 }], + request: fakeRequest, + }); + + assert.deepEqual(result, { status: 204, ok: true }); + assert.equal(destroyed, true); +}); + +test('propagates request failures without retrying or redirecting inside the transport', async () => { + const failure = new Error('connect failed'); + const fakeRequest = () => { + const req = new EventEmitter(); + req.end = () => queueMicrotask(() => req.emit('error', failure)); + req.destroy = (error) => req.emit('error', error); + return req; + }; + + await assert.rejects(postWebhookOnce({ + url: 'https://hooks.example.net/hook', + headers: {}, + body: '{}', + lookup: async () => [{ address: '8.8.8.8', family: 4 }], + request: fakeRequest, + }), failure); +});