diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 17f338fe..5ad9b338 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -128,3 +128,7 @@ **Vulnerability:** The backend CSV export for audit logs neutralized `=`, `+`, `-`, and `@` but failed to neutralize `|` (pipe) characters, allowing potential DDE (Dynamic Data Exchange) injection if exported logs were opened in spreadsheet software. **Learning:** Spreadsheet formula defenses must cover all command-style prefixes including `|` across all CSV export boundaries, both frontend and backend. **Prevention:** Update the sanitization regex in the backend export function to `/^[=+\-@|]/` so that all potentially executable spreadsheet payloads are prefixed with a single quote. +## 2026-08-26 - Fix SSRF in webhook URL creation +**Vulnerability:** The POST `/api/orgs/:id/webhooks` endpoint accepted webhook URLs without checking if they pointed to internal, private, or loopback addresses. Because `sendWebhook` blindly issued fetch requests to those URLs, an attacker could configure an internal endpoint (e.g. `http://127.0.0.1:8787/...` or cloud metadata instances) to induce the server into making unauthorized requests. +**Learning:** Webhook destinations must always be validated against a deny-list of internal/private IP blocks before being persisted or dispatched, regardless of how simple the payload is. +**Prevention:** Parse the provided `url` and check its `hostname` against a blocklist (e.g., `localhost`, `127.0.0.1`, `::1`, `169.254.169.254`) before accepting it. Ensure tests are updated to use safe mock domains like `example.com` instead of `127.0.0.1`. diff --git a/server/app.mjs b/server/app.mjs index c432a84f..5f58cc39 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -748,6 +748,17 @@ app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { 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); + + try { + const parsedUrl = new URL(url); + const hostname = parsedUrl.hostname.toLowerCase(); + if (['localhost', '127.0.0.1', '::1', '169.254.169.254', '0.0.0.0'].includes(hostname)) { + return c.json({ error: 'internal webhook destinations are not allowed' }, 400); + } + } catch { + return c.json({ error: 'valid http(s) 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)); diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index e536b908..ce9a9eb8 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: 'http://example.com/hook', events: ['project.update'] }) }); assert.equal(r.status, 200, 'create webhook'); const wh = await r.json(); assert.ok(wh.secret.startsWith('whsec_'), 'webhook secret returned once');