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.
## 2025-02-27 - [Server-Side Request Forgery (SSRF) in Webhooks]
**Vulnerability:** Webhooks endpoints allowed user-provided URLs to target internal resources and loopback addresses.
**Learning:** `fetch` calls will blindly follow user-provided addresses including internal IP ranges and `localhost`. Node's `fetch` does not intrinsically block these requests.
**Prevention:** Validate user-supplied webhook URLs by checking the parsed `url.hostname` against a blocklist of private network CIDR blocks (127.0.0.0/8, 10.0.0.0/8, etc.) and loopback strings, including obscure forms like IPv4-mapped IPv6 (e.g. `[::ffff:127.0.0.1]`).
30 changes: 30 additions & 0 deletions server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,35 @@ const metrics = {
attachmentStatusRefreshDeferred: 0,
};

function isSafeUrl(urlString) {
try {
const u = new URL(urlString);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
const h = u.hostname.toLowerCase();

// Explicitly block local/private domains and IPs
if (h === 'localhost' || h === '0.0.0.0' || h === '[::1]' || h === '[::]') return false;

// Block IPv4 loopback and private networks
if (h.startsWith('127.') ||
h.startsWith('169.254.') ||
h.startsWith('10.') ||
h.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) ||
h.startsWith('192.168.')) {
return false;
}

// Block IPv6 mapped formats
if (h.includes('7f00:1') || h.includes('::ffff:127.')) {
return false;
}

return true;
} catch {
return false;
}
}

// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`.
// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome
// per attempt β€” never blocks or fails the triggering request.
Expand Down Expand Up @@ -748,6 +777,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 (!isSafeUrl(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: 'https://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 targets a reachable public host

The webhook target was changed to https://example.com/hook, a real reachable host, while the assertions still require every delivery to fail with ok===0 and retry to attempt 2 within 900ms. A 2xx response sets ok=1 and skips the retry; a sandboxed CI with no DNS stalls until the 3000ms timeout, recording no second attempt in time. Either way the delivery assertions fail, where the former local refused port failed instantly.

Prompt for agents
The webhook creation test URL was changed to https://example.com/hook to satisfy the new isSafeUrl SSRF check, but the assertions at lines 289-296 still assume the delivery will fail (ok===0) and be retried to attempt 2 within a 900ms wait. example.com is a reachable public host: if it returns a 2xx the delivery records ok=1 and no retry occurs; if DNS is unavailable in CI the fetch stalls until the 3000ms abort timeout so the retry is not recorded within 900ms. Both cases break the test. Pick a host that passes isSafeUrl (not private/loopback) but is guaranteed unreachable so the connection fails fast, e.g. an RFC 5737 TEST-NET address like http://192.0.2.1:9/hook (verify isSafeUrl does not block 192.0.2.x), or otherwise adjust the assertions/timeout to match the new target's real behavior.
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