Skip to content

Implement Security Hardening for /webhooks/:id and Executor SSRF/Injection Protection - #7

Merged
quickerup merged 1 commit into
mainfrom
feat-security-hardening-webhooks-6358849313922997981
Jul 15, 2026
Merged

Implement Security Hardening for /webhooks/:id and Executor SSRF/Injection Protection#7
quickerup merged 1 commit into
mainfrom
feat-security-hardening-webhooks-6358849313922997981

Conversation

@quickerup

@quickerup quickerup commented Jul 15, 2026

Copy link
Copy Markdown
Owner

This PR implements security hardening for /webhooks/:id and the downstream executor.js pipeline. Specifically, it enforces mandatory authentication via X-Workflow-Secret (using crypto.timingSafeEqual), blocks unconfigured webhook nodes, removes input trust on client-supplied chat_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

    • Added webhook authentication using workflow secrets.
    • Added webhook rate limiting with clear unauthorized and throttled responses.
    • Added support for passing trigger payloads through workflow execution.
    • Improved HTTP template handling, including URL encoding and header sanitization.
    • Added protection against requests to unsafe internal or private network addresses.
    • Restricted notification chat destinations to configured allowlisted values.
  • Tests

    • Expanded coverage for webhook authentication, rate limiting, SSRF protection, and injection handling.

…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>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 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 @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The executor now threads trigger_payload through trigger nodes, escapes interpolated HTTP values, blocks unsafe URLs, and sanitizes headers. Webhook routes add secret validation and rate limiting, while notify dispatch derives chat IDs from an allowlist.

Changes

Executor payload and HTTP input hardening

Layer / File(s) Summary
Trigger payload and HTTP input hardening
scripts/executor.js, scripts/test-executor-ssrf.js
Trigger payloads flow into trigger outputs; HTTP interpolation supports escaping, header sanitization, SSRF checks, and corresponding executor tests.

Webhook authorization and throttling

Layer / File(s) Summary
Webhook authorization and throttling
worker/src/index.js, scripts/test-api.js, worker/wrangler.toml
Webhook requests validate configured secrets, enforce per-workflow rate limits, and are covered by authorization and throttling tests; Node.js compatibility is enabled for the worker.

Notify payload and chat ID control

Layer / File(s) Summary
Notify payload and chat ID control
worker/src/index.js
Client-provided chat_id values are removed, and notify dispatch derives the chat ID from the configured allowlist.

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
Loading

Possibly related PRs

Suggested reviewers: google-labs-jules[bot]

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main security hardening changes to /webhooks/:id and executor SSRF/injection protection.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-security-hardening-webhooks-6358849313922997981

Comment @coderabbitai help to get the list of available commands.

@quickerup
quickerup merged commit 41ec7c7 into main Jul 15, 2026
1 check was pending
@quickerup
quickerup deleted the feat-security-hardening-webhooks-6358849313922997981 branch July 15, 2026 03:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
scripts/test-api.js (1)

389-399: 🔒 Security & Privacy | 🔵 Trivial | 🏗️ Heavy lift

Test 9's chat_id trust-removal isn't actually asserted.

The comment says chat_id: 99999 is supplied "to test trust removal," but the only checks afterward (statusCode 500/GitHub-dispatch-failed or 200/ok) don't verify that 99999 was actually discarded server-side rather than forwarded. As written, this test would still pass even if the worker forwarded the client-supplied chat_id unchanged. Consider intercepting/mocking the outbound GitHub dispatch call (or otherwise capturing the client_payload) to assert chat_id matches the configured allowlist rather than 99999.

🤖 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 win

Add test coverage for the AWS-metadata and private-IP-range SSRF branches.

Only the localhost branch of isSafeUrl is exercised. Consider adding cases for 169.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

📥 Commits

Reviewing files that changed from the base of the PR and between d838664 and 35ba26f.

📒 Files selected for processing (5)
  • scripts/executor.js
  • scripts/test-api.js
  • scripts/test-executor-ssrf.js
  • worker/src/index.js
  • worker/wrangler.toml

Comment thread scripts/executor.js
Comment on lines +89 to +123
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 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 -S

Repository: 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.

Comment thread worker/src/index.js
Comment on lines +1 to +2
import crypto from 'node:crypto';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n --type=js -C2 '\bcrypto\.[a-zA-Z]+' worker/src/index.js

Repository: 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 || true

Repository: 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.js

Repository: 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.

Comment thread worker/src/index.js
Comment on lines +47 to +113
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 });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 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.

Comment thread worker/src/index.js
Comment on lines +86 to +112
// 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 });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant