Implement Security Hardening for /webhooks/:id and Executor SSRF/Injection Protection - #7
Conversation
…SSRF/injection protection - Restructured /webhooks/:id handler to require X-Workflow-Secret header - Authenticates webhooks with constant-time crypto.timingSafeEqual - Prohibits public execution of unconfigured webhook triggers (blocks with 403) - Removes client-supplied chat_id input trust, resolving chat ID solely from server-side ALLOWED_CHAT_IDS - Implements KV-based rate limiting per workflow ID (restricts to 5 requests per 60 seconds) - Configured 'nodejs_compat' compatibility_flags in wrangler.toml to enable node:crypto - Secured executor.js HTTP URL interpolation by URL-encoding trigger variables to prevent path traversal - Implemented robust SSRF loopback, local, and private subnets blocklist check on final HTTP request URLs - Sanitized HTTP headers against injection by stripping CRLF and control characters - Created comprehensive integration tests verifying webhook security and executor SSRF blocks Co-authored-by: aethelred-agent-factory <238771426+aethelred-agent-factory@users.noreply.github.com>
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
📝 WalkthroughWalkthroughThe executor now threads ChangesExecutor payload and HTTP input hardening
Webhook authorization and throttling
Notify payload and chat ID control
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant WebhookClient
participant POSTWebhooksRoute
participant WORKFLOW_STATE
participant executor.js
WebhookClient->>POSTWebhooksRoute: Submit webhook and secret
POSTWebhooksRoute->>WORKFLOW_STATE: Check workflow rate limit
POSTWebhooksRoute->>executor.js: Dispatch trigger payload
executor.js-->>WebhookClient: Workflow response or rejection
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
scripts/test-api.js (1)
389-399: 🔒 Security & Privacy | 🔵 Trivial | 🏗️ Heavy liftTest 9's chat_id trust-removal isn't actually asserted.
The comment says
chat_id: 99999is supplied "to test trust removal," but the only checks afterward (statusCode 500/GitHub-dispatch-failed or 200/ok) don't verify that99999was actually discarded server-side rather than forwarded. As written, this test would still pass even if the worker forwarded the client-suppliedchat_idunchanged. Consider intercepting/mocking the outbound GitHub dispatch call (or otherwise capturing theclient_payload) to assertchat_idmatches the configured allowlist rather than99999.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/test-api.js` around lines 389 - 399, The Test 9 flow around resTriggerWebhook does not verify trust removal for the supplied chat_id 99999. Capture or mock the outbound GitHub dispatch and inspect its client_payload, asserting that chat_id is replaced with the configured allowlisted value rather than forwarded unchanged, while preserving the existing response-status assertions.scripts/test-executor-ssrf.js (1)
40-119: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd test coverage for the AWS-metadata and private-IP-range SSRF branches.
Only the
localhostbranch ofisSafeUrlis exercised. Consider adding cases for169.254.169.254(metadata) and a private-range address (e.g.10.0.0.1,192.168.1.1,172.16.0.1) to lock in the behavior the PR explicitly claims to add.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/test-executor-ssrf.js` around lines 40 - 119, Add test cases in the SSRF executor test flow alongside the existing localhost case for an AWS metadata URL at 169.254.169.254 and at least one private-range URL such as 10.0.0.1. Reuse the payload structure and assertions from payloadLocalhost/runExecutor, verifying each request fails, its HTTP step is marked failed, and the error contains “SSRF Block”.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scripts/executor.js`:
- Around line 89-123: Harden isSafeUrl so it resolves the parsed hostname and
rejects URLs whose resolved addresses fall within private, loopback, link-local,
unspecified, or other internal IP ranges, including the full 169.254.0.0/16
range rather than only 169.254.169.254. Preserve the existing protocol
validation and ensure DNS resolution failures are rejected before HTTP nodes are
allowed.
In `@worker/src/index.js`:
- Around line 86-112: The WORKFLOW_STATE read-modify-write counter cannot
enforce an exact five-request limit under concurrency. Either replace this logic
with a Durable Object-backed atomic counter for a hard guarantee, or explicitly
document beside the existing rate-limiting block that the KV-based limit is
best-effort and may allow bursts due to eventual consistency.
- Around line 47-113: Move the rate-limiting logic currently guarded by the
authorization flow ahead of the `isAuthorized`/invalid-secret response so
unauthenticated webhook requests consume the per-workflow limit. Preserve the
existing `WORKFLOW_STATE`, `limitKey`, threshold, reset, and 429 behavior, while
ensuring authorized requests are not counted twice.
- Around line 1-2: Update safeCompare to hash both inputs with the existing
crypto import before comparing, and perform the constant-time comparison on the
resulting fixed-length digests. Remove the raw-input length check so mismatched
input lengths neither reveal the secret length nor bypass timing-safe
comparison.
---
Nitpick comments:
In `@scripts/test-api.js`:
- Around line 389-399: The Test 9 flow around resTriggerWebhook does not verify
trust removal for the supplied chat_id 99999. Capture or mock the outbound
GitHub dispatch and inspect its client_payload, asserting that chat_id is
replaced with the configured allowlisted value rather than forwarded unchanged,
while preserving the existing response-status assertions.
In `@scripts/test-executor-ssrf.js`:
- Around line 40-119: Add test cases in the SSRF executor test flow alongside
the existing localhost case for an AWS metadata URL at 169.254.169.254 and at
least one private-range URL such as 10.0.0.1. Reuse the payload structure and
assertions from payloadLocalhost/runExecutor, verifying each request fails, its
HTTP step is marked failed, and the error contains “SSRF Block”.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 097dcd47-fd8b-4108-b2da-dbc4ace6f781
📒 Files selected for processing (5)
scripts/executor.jsscripts/test-api.jsscripts/test-executor-ssrf.jsworker/src/index.jsworker/wrangler.toml
| function isSafeUrl(urlString) { | ||
| try { | ||
| const parsed = new URL(urlString); | ||
| if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { | ||
| return false; | ||
| } | ||
| const hostname = parsed.hostname.toLowerCase(); | ||
| // Block localhost / loopback / standard unsafe addresses | ||
| if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') { | ||
| return false; | ||
| } | ||
| // Block AWS / Cloud metadata Link-local | ||
| if (hostname === '169.254.169.254') { | ||
| return false; | ||
| } | ||
| // Block private IP ranges | ||
| if ( | ||
| hostname.startsWith('10.') || | ||
| hostname.startsWith('192.168.') || | ||
| (hostname.startsWith('172.') && isPrivate172(hostname)) | ||
| ) { | ||
| return false; | ||
| } | ||
| return true; | ||
| } catch (e) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function isPrivate172(hostname) { | ||
| const parts = hostname.split('.'); | ||
| if (parts.length < 2) return false; | ||
| const secondOctet = parseInt(parts[1], 10); | ||
| return secondOctet >= 16 && secondOctet <= 31; | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the file and inspect the relevant range
wc -l scripts/executor.js
sed -n '1,220p' scripts/executor.js
# Find uses of isSafeUrl and any DNS/IP validation nearby
rg -n "isSafeUrl|dns\.|lookup\(|fetch\(|169\.254|127\.0\.0\.1|localhost|fc00|fe80|::ffff" scripts/executor.js scripts -SRepository: quickerup/telegram-workflow-bot
Length of output: 11131
Harden the SSRF guard before allowing HTTP nodes. isSafeUrl() only checks the literal hostname, so a public domain that resolves to an internal IP still passes and gets fetched later. It also blocks just 169.254.169.254, leaving the rest of the link-local/private space unchecked.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/executor.js` around lines 89 - 123, Harden isSafeUrl so it resolves
the parsed hostname and rejects URLs whose resolved addresses fall within
private, loopback, link-local, unspecified, or other internal IP ranges,
including the full 169.254.0.0/16 range rather than only 169.254.169.254.
Preserve the existing protocol validation and ensure DNS resolution failures are
rejected before HTTP nodes are allowed.
| import crypto from 'node:crypto'; | ||
|
|
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n --type=js -C2 '\bcrypto\.[a-zA-Z]+' worker/src/index.jsRepository: quickerup/telegram-workflow-bot
Length of output: 1507
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- file map ---'
ast-grep outline worker/src/index.js --view expanded | sed -n '1,220p'
echo
echo '--- top of file ---'
sed -n '1,120p' worker/src/index.js | cat -n
echo
echo '--- crypto.subtle / webcrypto references in file ---'
rg -n '\bcrypto\.subtle\b|\bwebcrypto\b' worker/src/index.js || trueRepository: quickerup/telegram-workflow-bot
Length of output: 7387
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- lines 1588-1625 ---'
sed -n '1588,1625p' worker/src/index.js | cat -n
echo
echo '--- all timingSafeEqual references ---'
rg -n '\btimingSafeEqual\b' worker/src/index.jsRepository: quickerup/telegram-workflow-bot
Length of output: 2188
safeCompare should hash both inputs before comparing. The current length check leaks the secret length and skips the constant-time path on mismatched inputs.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@worker/src/index.js` around lines 1 - 2, Update safeCompare to hash both
inputs with the existing crypto import before comparing, and perform the
constant-time comparison on the resulting fixed-length digests. Remove the
raw-input length check so mismatched input lengths neither reveal the secret
length nor bypass timing-safe comparison.
| const clientSecret = request.headers.get('X-Workflow-Secret'); | ||
|
|
||
| const safeCompare = (a, b) => { | ||
| if (typeof a !== 'string' || typeof b !== 'string') return false; | ||
| const aBuf = new TextEncoder().encode(a); | ||
| const bBuf = new TextEncoder().encode(b); | ||
| if (aBuf.byteLength !== bBuf.byteLength) return false; | ||
| return crypto.timingSafeEqual(aBuf, bBuf); | ||
| }; | ||
|
|
||
| let hasConfiguredSecret = false; | ||
| let isAuthorized = false; | ||
|
|
||
| for (const row of nodesRows) { | ||
| if (row.type === 'webhook_trigger') { | ||
| const config = JSON.parse(row.config || '{}'); | ||
| if (config && typeof config.secret === 'string' && config.secret.trim() !== '') { | ||
| hasConfiguredSecret = true; | ||
| if (clientSecret && safeCompare(clientSecret, config.secret)) { | ||
| isAuthorized = true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!hasConfiguredSecret) { | ||
| return new Response(JSON.stringify({ ok: false, error: 'Unauthorized: Webhook trigger has no configured secret' }), { | ||
| status: 403, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }); | ||
| } | ||
|
|
||
| if (!isAuthorized) { | ||
| return new Response(JSON.stringify({ ok: false, error: 'Unauthorized: Invalid X-Workflow-Secret' }), { | ||
| status: 401, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }); | ||
| } | ||
|
|
||
| // Rate Limiting using WORKFLOW_STATE KV per workflow ID (limit to 5 requests per 60s) | ||
| if (env.WORKFLOW_STATE) { | ||
| const limitKey = `ratelimit:${id}`; | ||
| const limitInfoRaw = await env.WORKFLOW_STATE.get(limitKey); | ||
| let limitInfo = { count: 0, reset: Date.now() + 60000 }; | ||
| if (limitInfoRaw) { | ||
| try { | ||
| limitInfo = JSON.parse(limitInfoRaw); | ||
| } catch (e) {} | ||
| } | ||
|
|
||
| if (Date.now() > limitInfo.reset) { | ||
| limitInfo.count = 0; | ||
| limitInfo.reset = Date.now() + 60000; | ||
| } | ||
|
|
||
| const RATE_LIMIT_THRESHOLD = 5; | ||
| if (limitInfo.count >= RATE_LIMIT_THRESHOLD) { | ||
| return new Response(JSON.stringify({ ok: false, error: 'Too Many Requests: Rate limit exceeded.' }), { | ||
| status: 429, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }); | ||
| } | ||
|
|
||
| limitInfo.count++; | ||
| await env.WORKFLOW_STATE.put(limitKey, JSON.stringify(limitInfo), { expirationTtl: 60 }); | ||
| } | ||
|
|
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Rate limiting doesn't cover failed authentication attempts — unlimited brute-force guessing of the webhook secret.
The rate-limit block (lines 86-112) only runs after isAuthorized is already true (line 79). A request with a wrong X-Workflow-Secret returns 401 before ever reaching the limiter, so an attacker can send unlimited guesses at the secret with no throttling at all. Given safeCompare is otherwise timing-safe, this leaves brute-force as the main remaining attack surface on weak/short secrets.
Move (a copy of) the throttling check ahead of the secret comparison — or track a separate failed-attempt counter per workflow — so repeated invalid attempts are also capped.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@worker/src/index.js` around lines 47 - 113, Move the rate-limiting logic
currently guarded by the authorization flow ahead of the
`isAuthorized`/invalid-secret response so unauthenticated webhook requests
consume the per-workflow limit. Preserve the existing `WORKFLOW_STATE`,
`limitKey`, threshold, reset, and 429 behavior, while ensuring authorized
requests are not counted twice.
| // Rate Limiting using WORKFLOW_STATE KV per workflow ID (limit to 5 requests per 60s) | ||
| if (env.WORKFLOW_STATE) { | ||
| const limitKey = `ratelimit:${id}`; | ||
| const limitInfoRaw = await env.WORKFLOW_STATE.get(limitKey); | ||
| let limitInfo = { count: 0, reset: Date.now() + 60000 }; | ||
| if (limitInfoRaw) { | ||
| try { | ||
| limitInfo = JSON.parse(limitInfoRaw); | ||
| } catch (e) {} | ||
| } | ||
|
|
||
| if (Date.now() > limitInfo.reset) { | ||
| limitInfo.count = 0; | ||
| limitInfo.reset = Date.now() + 60000; | ||
| } | ||
|
|
||
| const RATE_LIMIT_THRESHOLD = 5; | ||
| if (limitInfo.count >= RATE_LIMIT_THRESHOLD) { | ||
| return new Response(JSON.stringify({ ok: false, error: 'Too Many Requests: Rate limit exceeded.' }), { | ||
| status: 429, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }); | ||
| } | ||
|
|
||
| limitInfo.count++; | ||
| await env.WORKFLOW_STATE.put(limitKey, JSON.stringify(limitInfo), { expirationTtl: 60 }); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
KV-based counter has a read-modify-write race and KV is not built for exact accounting.
get → mutate → put here isn't atomic: concurrent requests can read the same count, each pass the < 5 check, and each write back an incremented value, letting more than 5 requests through in a burst. This is compounded by KV's documented consistency model — KV is eventually consistent. Two requests hitting at the same millisecond might both read count: 0. That's fine for soft limits. Cloudflare's own Rate Limiting binding has the same caveat: the Rate Limiting API is permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system. For example, if many requests come in to your Worker in a single Cloudflare location, all rate limited on the same key, the isolate that serves each request will check against its locally cached value of the rate limit.
If "5 requests per 60s" needs to be a hard guarantee, a Durable Object (single logical instance, can do atomic in-memory/SQLite counters) is the documented fix; if this is meant as best-effort abuse mitigation, that's a reasonable tradeoff but worth calling out explicitly (e.g. in a comment) so it isn't mistaken for a strict guarantee later.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@worker/src/index.js` around lines 86 - 112, The WORKFLOW_STATE
read-modify-write counter cannot enforce an exact five-request limit under
concurrency. Either replace this logic with a Durable Object-backed atomic
counter for a hard guarantee, or explicitly document beside the existing
rate-limiting block that the KV-based limit is best-effort and may allow bursts
due to eventual consistency.
This PR implements security hardening for
/webhooks/:idand the downstreamexecutor.jspipeline. Specifically, it enforces mandatory authentication viaX-Workflow-Secret(usingcrypto.timingSafeEqual), blocks unconfigured webhook nodes, removes input trust on client-suppliedchat_ids, and restricts workflow trigger frequencies via a KV-based rate-limiter. In the executor, it URL-encodes interpolated trigger variables, validates that target URLs are public HTTP/HTTPS URLs (blocking private IP ranges, AWS metadata IPs, and localhost), and sanitizes HTTP headers of CRLF injection characters. Comprehensive test suites have been updated/written and successfully pass.PR created automatically by Jules for task 6358849313922997981 started by @aethelred-agent-factory
Summary by CodeRabbit
New Features
Tests