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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
11 changes: 11 additions & 0 deletions server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment on lines +752 to +760

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 denylist is incomplete and bypassable

The check rejects only five literal hostnames. It misses private ranges (10/8, 172.16/12, 192.168/16), the rest of loopback and link-local, IPv6 internal ranges, and alternate IP encodings (decimal, octal, hex). Because only the submitted hostname is checked, a public DNS name resolving to an internal IP, or a public host that redirects inward, still reaches internal targetsβ€”fetch in sendWebhook follows redirects unvalidated (server/app.mjs:106).

Open in Devin Review

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


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/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 test now depends on external network

The webhook test posts to http://example.com/hook instead of a port that refuses instantly, so the failure-and-retry assertions now depend on an outside host. If outbound traffic is dropped, the fetch stalls until the 3s abort, past the 900ms wait, and the attempt-2 retry is never recorded, failing the test. If the host answers 2xx, no retry is scheduled and the assertions fail too.

Prompt for agents
The webhook delivery assertions at tests/api/smoke.mjs:289-296 require the delivery to fail (ok===0) and be retried to attempt 2 within a 900ms wait. Previously the test used http://127.0.0.1:9/hook, which produces an instant, deterministic connection refusal on the loopback interface. The SSRF fix in POST /api/orgs/:id/webhooks now blocks 127.0.0.1, so the test was switched to http://example.com/hook. This makes the failure/retry assertions depend on outbound internet reachability, on example.com returning a non-2xx status, and on the response arriving fast enough that the retry (scheduled 500ms after the first failure) lands inside the 900ms window. In a sandboxed CI environment that drops outbound packets, the fetch will hang until the 3s AbortController timeout in sendWebhook, so the first delivery is not recorded until ~3s and no attempt-2 row exists at 900ms. Consider using a URL that is guaranteed to fail fast yet is not on the SSRF blocklist β€” e.g. a reserved TEST-NET address like http://192.0.2.1:9/hook (RFC 5737, unroutable, fast to fail) or a .invalid domain like http://nonexistent.invalid/hook that fails DNS instantly β€” so the test stays deterministic and offline-safe.
Open in 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