Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,8 @@
**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.

## 2024-05-27 - Server-Side Request Forgery (SSRF) in webhooks
**Vulnerability:** The POST `/api/orgs/:id/webhooks` handler allowed creating webhooks with internal/private IP addresses (e.g., `127.0.0.1`, `localhost`, `10.x.x.x`), leading to potential SSRF vulnerabilities when the server attempts to deliver events.
**Learning:** Webhook URLs must be strictly validated not just for proper HTTP(S) formatting, but also to ensure they point to external, publicly routable hostnames/IPs to prevent the application server from attacking internal infrastructure.
**Prevention:** Implement an `isSafeWebhookUrl` function using the `URL` constructor to normalize inputs and block common internal network patterns (loopback, local, RFC1918) before saving webhook configurations.
26 changes: 26 additions & 0 deletions server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,31 @@ app.get('/api/metrics', (c) => {
});

// ------------------------------------------------------------------- webhooks

function isSafeWebhookUrl(urlStr) {
let u;
try {
u = new URL(urlStr);
} catch {
return false;
}
const hn = u.hostname;
if (
hn === 'localhost' ||
hn === '[::1]' ||
/^127\.\d+\.\d+\.\d+$/.test(hn) ||
/^10\.\d+\.\d+\.\d+$/.test(hn) ||
/^192\.168\.\d+\.\d+$/.test(hn) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\.\d+\.\d+$/.test(hn) ||
/^169\.254\.\d+\.\d+$/.test(hn) ||
/^0\.\d+\.\d+\.\d+$/.test(hn) ||
hn === ''
) {
return false;
}
return true;
}
Comment on lines +733 to +755

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Webhook SSRF filter bypassable via IP encodings and DNS

isSafeWebhookUrl rejects only dotted-decimal private ranges, localhost, and [::1], matching the raw hostname string rather than the resolved address. Integer/hex IPs (http://2130706433/), non-[::1] IPv6 loopback and IPv4-mapped forms, and any public hostname whose DNS resolves to an internal or 169.254.169.254 metadata address all pass, and sendWebhook then fetches them server-side.

Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.


app.get('/api/orgs/:id/webhooks', requireAuth, (c) => {
const uid = c.get('user').sub;
const orgId = c.req.param('id');
Expand All @@ -748,6 +773,7 @@ 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);
if (!isSafeWebhookUrl(url)) return c.json({ error: 'unsafe webhook url' }, 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));
Expand Down
2 changes: 1 addition & 1 deletion tests/api/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:9/hook', events: ['project.update'] }) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”΄ Webhook delivery test breaks on switch to remote host

The webhook URL was changed from http://127.0.0.1:9/hook to http://example.com:9/hook. The local address failed instantly with connection-refused, so both delivery attempts recorded within the 900ms wait; the external host silently drops port 9, so sendWebhook hangs until its 3000ms abort. At 900ms no delivery is recorded, so the dels.length >= 2 and attempt === 2 assertions fail.

Prompt for agents
The webhook delivery test at tests/api/smoke.mjs was changed to use http://example.com:9/hook to satisfy the new isSafeWebhookUrl SSRF check, which now rejects 127.0.0.1. The problem: the downstream assertions (lines 290-296) rely on both delivery attempts failing fast and being recorded within a 900ms wait. Against 127.0.0.1:9 the connection was refused immediately, but example.com:9 is an external host that silently drops the connection, so sendWebhook (server/app.mjs:102) will hang until its 3000ms AbortController timeout on each attempt. With attempt 1 recording at ~3s and attempt 2 at ~6.5s, the 900ms wait elapses before any delivery row exists, so dels.length >= 2 and attempt === 2 fail; the test also now depends on real outbound network/DNS. Consider using a URL that passes isSafeWebhookUrl (public/external-looking) but still yields a fast, deterministic connection failure β€” e.g. a hostname that resolves but with a port that produces an immediate refusal, or a domain guaranteed not to resolve β€” or increase the wait to exceed the retry timeouts. Ensure the fix keeps the test deterministic and offline-safe.
Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

assert.equal(r.status, 200, 'create webhook');
const wh = await r.json();
assert.ok(wh.secret.startsWith('whsec_'), 'webhook secret returned once');
Expand Down
Loading