Skip to content

Phase/webhook ingress security#3

Merged
Ryanakml merged 4 commits intomainfrom
phase/webhook-ingress-security
Mar 6, 2026
Merged

Phase/webhook ingress security#3
Ryanakml merged 4 commits intomainfrom
phase/webhook-ingress-security

Conversation

@Ryanakml
Copy link
Copy Markdown
Owner

@Ryanakml Ryanakml commented Mar 6, 2026

Summary by CodeRabbit

  • New Features

    • Enhanced webhook processing with built-in idempotency, signature verification, and rate limiting
    • Added health and readiness monitoring endpoints
    • Improved error handling with better diagnostics for malformed payloads and size violations
    • Enhanced observability with structured tracing and correlation ID tracking
  • Chores

    • Added comprehensive test suite for API server
    • Updated infrastructure dependencies for webhook queuing

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive webhook ingress system with Redis-backed idempotency, BullMQ-based job queuing, observability hooks, signature verification, and dependency injection. The core API is refactored from a basic Express bootstrap to a modular, testable architecture with extensive configuration options and error handling across the monorepo.

Changes

Cohort / File(s) Summary
Core API Implementation
apps/api/src/index.ts, apps/api/src/index.js, apps/api/src/index.d.ts
Major refactor introducing webhook handling with Redis idempotency store, BullMQ ingress queue, HMAC-SHA256 signature verification, rate limiting, body buffering, structured error responses, and observability hooks (ingress_start, verification_failure, malformed_payload, duplicate_hit, enqueue_success, enqueue_failure). Exports factory functions: createRedisIdempotencyStore, createBullMqIngressQueue, createApp, startServer.
API Testing
apps/api/src/index.test.ts, apps/api/src/index.test.js, apps/api/src/index.test.d.ts
Comprehensive test suite (1,085 lines total) verifying health/ready endpoints, webhook verification flow, signature validation, body limits, deduplication, rate limiting, enqueue outcomes, observability event emission, correlation ID tracking, and ACK latency (p95). Uses in-memory dependencies for isolated testing.
Environment & Configuration
apps/api/.env.example, apps/worker/.env.example, packages/config/src/index.ts, packages/config/src/index.js, packages/config/src/index.d.ts
Adds WEBHOOK_IDEMPOTENCY_TTL_SECONDS as optional environment variable across API and worker apps, extending the config validation layer to recognize and include this setting in RuntimeEnv typing.
Build & Dependencies
apps/api/package.json, apps/worker/package.json, apps/worker/tsconfig.json, eslint.config.mjs
API package.json adds bullmq (^5.58.0), redis (^5.8.2) dependencies and test script. Worker updates build/typecheck scripts to run config workspace first. Worker tsconfig removes path alias mappings and aligns rootDir to ./src. ESLint config adds Buffer and fetch as readonly globals.
Dashboard Integration
apps/dashboard/middleware.ts
Introduces publicRoutes list with "/health" endpoint, short-circuiting security checks for public routes before authentication and role-based access validation.
Cleanup
scripts/local-dev-down.sh
Removes duplicate shebang and docker compose down command invocation from script initialization block.

Sequence Diagram

sequenceDiagram
    actor Client
    participant API as API Server
    participant Redis as Redis<br/>(Idempotency Store)
    participant BullMQ as BullMQ<br/>(Job Queue)
    participant Observability as Observability<br/>Hooks

    Client->>API: POST /webhook<br/>(with signature & payload)
    API->>Observability: onIngressStart()
    
    alt Signature Verification
        API->>API: Verify HMAC-SHA256
        alt Valid Signature
            API->>API: Extract eventKey<br/>from payload
        else Invalid/Missing Signature
            API->>Observability: onVerificationFailure()
            API->>Client: 401 Unauthorized
        end
    end

    alt Payload Validation
        API->>API: Parse & validate JSON<br/>Check size limits
        alt Invalid Payload
            API->>Observability: onMalformedPayload()
            API->>Client: 400 Bad Request
        end
    end

    alt Idempotency Check
        API->>Redis: setIfNotExists(dedupe_key,<br/>ttl_seconds)
        alt Key Already Exists
            API->>Observability: onDuplicateHit()
            API->>Client: 200 OK (deduplicated)
        else Key Inserted
            API->>BullMQ: enqueue(IngressJobPayload)
            alt Enqueue Success
                API->>Observability: onEnqueueSuccess()
                API->>Client: 202 Accepted
            else Enqueue Failure
                API->>Observability: onEnqueueFailure()
                API->>Redis: delete(dedupe_key)
                API->>Client: 500 Server Error
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Hops through webhooks with idempotency grace,
Redis guards duplicates in their rightful place,
BullMQ queues jobs with observant care,
Signatures verified, signatures fair,
A rabbit's delight—webhooks made right! 🐇✨

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Phase/webhook ingress security' accurately reflects the main changes: implementing webhook ingress security features with signature verification, idempotency, rate limiting, and observability.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch phase/webhook-ingress-security

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/src/index.js`:
- Around line 397-399: The /ready route currently always returns OK; change the
app.get('/ready', ...) handler to perform the same dependency checks used by the
POST '/webhook' path (e.g., verify Redis client connectivity and BullMQ queue
readiness or call the same helper used by webhook enqueuing), and return 200
only if those checks succeed otherwise return 503 with an error body explaining
which dependency failed; reuse existing functions or clients referenced by the
POST '/webhook' logic so readiness mirrors webhook runtime requirements.
- Around line 269-270: The adminRateLimitStore and webhookRateLimitStore
currently grow unbounded because keys are never deleted; replace them with a
TTL-backed cache or add an expiry wrapper plus a periodic sweeper: either swap
new Map() for a small dependency like node-cache or lru-cache configured with a
TTL, or keep the Map but store objects {count, expiresAt} and add a setInterval
cleanup that deletes expired keys; apply the same change to the other rate-limit
buckets used elsewhere in this file (the other Map-based rate-limit stores) and
ensure any increment/check logic (where these stores are read or updated) uses
the expiry semantics so stale entries get removed or reused.
- Around line 309-316: The isHttpsRequest helper reads the X-Forwarded-Proto
header directly (allowing spoofing when Express trust proxy is not enabled);
replace that logic so isHttpsRequest only relies on req.secure and stop parsing
req.headers['x-forwarded-proto'] directly. If you actually intend to honor
forwarded headers, configure Express trust proxy from TRUST_PROXY (e.g.
app.set('trust proxy', process.env.TRUST_PROXY)) and document that in
.env.example; otherwise remove the header parsing and return req.secure in
isHttpsRequest.
- Around line 195-210: The Redis client returned by createRedisIdempotencyStore
must register an 'error' listener before calling redis.connect(); modify
createRedisIdempotencyStore so that immediately after const redis =
createClient({ url: redisUrl }) you add a noop or logging listener on
redis.on('error', ...) to prevent uncaught error throws, keep ensureConnected
and connectPromise logic unchanged but ensure the listener is attached prior to
invoking redis.connect() inside ensureConnected; reference
createRedisIdempotencyStore, the local redis variable, and ensureConnected when
making this change.
- Around line 227-234: The createBullMqIngressQueue function is passing
connection: { url: redisUrl } which ioredis ignores; instead import/construct an
ioredis client (e.g., new Redis(redisUrl)) and pass that instance as the
connection to Queue (the Queue call that uses INGRESS_QUEUE_NAME and
defaultJobOptions). Update createBullMqIngressQueue to create the Redis
instance, pass connection: redisInstance into the Queue constructor, and ensure
the Redis import (Redis or ioredis) is present and the client lifecycle is
managed as appropriate.

In `@apps/api/src/index.test.ts`:
- Around line 170-179: Await the server's 'listening' event after calling
app.listen(0) and await the 'close' event when tearing down to avoid race
conditions: import once from 'node:events', replace the immediate call to
server.address() with await once(server, 'listening') then call server.address()
and use its port for baseUrl, and when cleaning up call server.close() and await
once(server, 'close') (or await a Promise wrapper around server.close) so the
server is fully started before reading address and fully closed before test
exit; refer to the server variable, app.listen(0), server.address(),
server.close(), and the once helper.

In `@apps/api/src/index.ts`:
- Around line 548-550: The readiness endpoint implemented in the
app.get('/ready') handler must verify external dependencies (Redis and BullMQ)
before returning ok; modify the '/ready' route handler to perform health checks
against the Redis client used for idempotency key reservation and the BullMQ
Queue/QueueScheduler or Worker used for enqueuing jobs (i.e., call their
ping/health/status methods or attempt a lightweight operation such as PING for
Redis and getJobCounts/getRepeatableJobs or queue.client.ping for BullMQ) and
return a non-200 / { ok: false } response when either check fails so the process
is not marked ready if it cannot reserve idempotency keys or enqueue jobs.
Ensure the updated logic is in the same app.get('/ready', ...) handler and
logs/returns clear failure state.
- Around line 603-627: The current flow calls idempotencyStore.setIfNotExists
outside any guard and runs observability.onEnqueueSuccess inside the same try
that rolls back the dedupe key; fix by: 1) wrap idempotencyStore.setIfNotExists
in its own try/catch and call sendWebhookError on store failures so store errors
follow the structured error path; 2) perform ingressQueue.enqueue in a dedicated
try/catch that only deletes the dedupe key when the enqueue itself fails; and 3)
move observability.onEnqueueSuccess out of the enqueue failure path (or invoke
it in a separate try/catch that swallows/logs errors) so exceptions from
observability.onEnqueueSuccess cannot trigger idempotencyStore.delete and turn a
successful enqueue into a retried duplicate.
- Around line 258-263: The dedupe key only uses statuses[*].id (message id) so
different status updates for the same message collide; modify the block that
builds eventParts (the code using valueRecord.statuses, asRecord, and
statusRecord.id) to include the status transition identifier as well (e.g.,
append statusRecord.status or statusRecord.event or statusRecord.type if
present) into the key—fall back to the current id-only behavior only if no
transition field exists—so each distinct status change (sent/delivered/read)
produces a unique dedupe key.
- Around line 326-331: The setIfNotExists function uses ttlSeconds without
ensuring it is a positive integer; update the store boundary (setIfNotExists) to
validate and normalize ttlSeconds (from options.idempotencyTtlSeconds or any
caller) before calling redis.set: ensure it's a finite integer > 0 (e.g.,
parseNumber result must be > 0 and you should Math.floor/truncate decimals) and
throw or return an explicit error when invalid, so Redis SET EX never receives
zero, negative, or non-integer values; also update or centralize validation
logic used by parseNumber to enforce this rule.
- Around line 437-444: The isHttpsRequest helper bypasses Express's trust-proxy
protection by reading the raw 'x-forwarded-proto' header; update isHttpsRequest
to stop inspecting req.headers['x-forwarded-proto'] and rely solely on
req.secure (which respects the app's trust proxy setting) so that spoofed
headers cannot override HTTPS enforcement (keep the function name isHttpsRequest
and remove the manual header parsing logic).

In `@apps/dashboard/middleware.ts`:
- Around line 25-27: The current public-route check uses
pathname.startsWith(route) which allows prefixes like "/health" to match
"/healthz"; update the logic in the middleware where publicRoutes and pathname
are checked (the block that currently returns NextResponse.next()) to require
either an exact match (pathname === route) or a proper subpath boundary by
checking pathname.startsWith(route + "/") for routes that should allow nested
paths; ensure both checks are applied for each entry in publicRoutes so only the
declared path or its explicit subpaths are treated as public.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1b67db2-4f3d-4d15-a10f-32302a2a9d24

📥 Commits

Reviewing files that changed from the base of the PR and between ac16171 and 5490d8a.

⛔ Files ignored due to path filters (7)
  • apps/api/src/index.d.ts.map is excluded by !**/*.map
  • apps/api/src/index.js.map is excluded by !**/*.map
  • apps/api/src/index.test.d.ts.map is excluded by !**/*.map
  • apps/api/src/index.test.js.map is excluded by !**/*.map
  • package-lock.json is excluded by !**/package-lock.json
  • packages/config/src/index.d.ts.map is excluded by !**/*.map
  • packages/config/src/index.js.map is excluded by !**/*.map
📒 Files selected for processing (17)
  • apps/api/.env.example
  • apps/api/package.json
  • apps/api/src/index.d.ts
  • apps/api/src/index.js
  • apps/api/src/index.test.d.ts
  • apps/api/src/index.test.js
  • apps/api/src/index.test.ts
  • apps/api/src/index.ts
  • apps/dashboard/middleware.ts
  • apps/worker/.env.example
  • apps/worker/package.json
  • apps/worker/tsconfig.json
  • eslint.config.mjs
  • packages/config/src/index.d.ts
  • packages/config/src/index.js
  • packages/config/src/index.ts
  • scripts/local-dev-down.sh
💤 Files with no reviewable changes (1)
  • scripts/local-dev-down.sh

Comment on lines +195 to +210
export const createRedisIdempotencyStore = (redisUrl) => {
const redis = createClient({ url: redisUrl });
let connectPromise = null;
const ensureConnected = async () => {
if (redis.isOpen) {
return;
}
if (!connectPromise) {
connectPromise = redis
.connect()
.then(() => undefined)
.finally(() => {
connectPromise = null;
});
}
await connectPromise;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Register a Redis error listener before connecting.

createClient() is an EventEmitter, and node-redis explicitly requires at least one error listener. Without it, a transient Redis/network failure on this ingress path can throw and take down the API process instead of surfacing a controlled 5xx. (github.com)

🔧 Minimal hardening
 export const createRedisIdempotencyStore = (redisUrl) => {
     const redis = createClient({ url: redisUrl });
+    redis.on('error', (error) => {
+        console.error(JSON.stringify({
+            ts: new Date().toISOString(),
+            level: 'error',
+            event: 'redis.client.error',
+            error: error.message,
+        }));
+    });
     let connectPromise = null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.js` around lines 195 - 210, The Redis client returned by
createRedisIdempotencyStore must register an 'error' listener before calling
redis.connect(); modify createRedisIdempotencyStore so that immediately after
const redis = createClient({ url: redisUrl }) you add a noop or logging listener
on redis.on('error', ...) to prevent uncaught error throws, keep ensureConnected
and connectPromise logic unchanged but ensure the listener is attached prior to
invoking redis.connect() inside ensureConnected; reference
createRedisIdempotencyStore, the local redis variable, and ensureConnected when
making this change.

Comment on lines +227 to +234
export const createBullMqIngressQueue = (redisUrl) => {
const queue = new Queue(INGRESS_QUEUE_NAME, {
connection: { url: redisUrl },
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: false,
},
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check the actual code in the file
cd apps/api/src && cat -n index.js | sed -n '227,234p'

Repository: Ryanakml/ChatFuse

Length of output: 378


🌐 Web query:

BullMQ connection configuration ioredis URL options

💡 Result:

BullMQ’s connection option is passed straight to ioredis (so you configure Redis exactly the way ioredis expects). (docs.bullmq.io)

What you can pass to connection

1) An ioredis options object

import { Queue } from "bullmq";

const queue = new Queue("myqueue", {
  connection: {
    host: "localhost",
    port: 6379,
    username: "default",
    password: "secret",
    db: 0,
    // tls: { ... } // if needed
  },
});

BullMQ warns not to use ioredis keyPrefix; use BullMQ’s own prefix option instead. (docs.bullmq.io)

2) An existing ioredis client instance (best way to use a URL)

ioredis supports creating a client from a Redis URL string (URI). (ioredis.readthedocs.io)

import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";

const redis = new IORedis(process.env.REDIS_URL!, {
  // extra ioredis options here (e.g. tls, reconnect strategy, etc.)
});

const queue = new Queue("myqueue", { connection: redis });

const worker = new Worker("myqueue", async job => {}, {
  connection: redis,
});

Notes on “URL options”

  • The URL itself can include user:password@host:port/db (standard Redis URI form). (ioredis.readthedocs.io)
  • For other settings (TLS, retry strategies, timeouts, etc.), pass them in the second argument to new IORedis(url, options) as shown above. (ioredis.readthedocs.io)

If you tell me your exact Redis URL (redacting secrets) and what you’re trying to set (TLS, Sentinel, Cluster, VPC, etc.), I can show the precise BullMQ + ioredis config.

Citations:


🌐 Web query:

ioredis connection options url parameter format

💡 Result:

In ioredis, the “URL” (connection string) follows the standard Redis URI form:

  • TCP (no TLS):
    redis://[[username]:[password]@]host[:port][/db]
  • TCP (TLS):
    rediss://[[username]:[password]@]host[:port][/db]

Examples from the official README:

new Redis("redis://:authpassword@127.0.0.1:6380/4");          // password + db
new Redis("redis://username:authpassword@127.0.0.1:6380/4");  // username + password + db
new Redis("rediss://redis.my-service.com");                   // TLS via scheme

[1]

Important about “URL parameters” (?key=value): ioredis documents the URL form as above (auth in authority, db in the path). For other connection options (e.g., tls settings, connectTimeout, etc.), pass them as the options object (either as the constructor argument, or as the 2nd argument when the 1st is a URL). [2]

Constructor shape (URL + options object):

new Redis("redis://user:pass@host:6379/0", { /* extra options like tls, timeouts */ });

[2]

Sources:
[1] ioredis GitHub README (connection string examples, redis:// and rediss://) (github.com)
[2] ioredis API docs (new Redis([port], [host], [options]), URL string + options object) (ioredis.readthedocs.io)

Citations:


Pass BullMQ an ioredis client instance, not a url property in an options object.

BullMQ forwards the connection option directly to ioredis. ioredis accepts Redis URLs as a constructor string (e.g., new Redis("redis://..."), not as a url property in an options object. The current code passes connection: { url: redisUrl }, which ioredis will not recognize—causing it to ignore REDIS_URL and fall back to localhost defaults.

The correct approach is to create an ioredis instance first and pass it to BullMQ:

const redis = new Redis(redisUrl);
const queue = new Queue(INGRESS_QUEUE_NAME, {
    connection: redis,
    defaultJobOptions: { ... },
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.js` around lines 227 - 234, The createBullMqIngressQueue
function is passing connection: { url: redisUrl } which ioredis ignores; instead
import/construct an ioredis client (e.g., new Redis(redisUrl)) and pass that
instance as the connection to Queue (the Queue call that uses INGRESS_QUEUE_NAME
and defaultJobOptions). Update createBullMqIngressQueue to create the Redis
instance, pass connection: redisInstance into the Queue constructor, and ensure
the Redis import (Redis or ioredis) is present and the client lifecycle is
managed as appropriate.

Comment on lines +269 to +270
const adminRateLimitStore = new Map();
const webhookRateLimitStore = new Map();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prune or externalize these rate-limit buckets.

Both stores only ever accumulate keys. Expired entries are overwritten on reuse but never deleted, so a spray of unique IPs can keep growing memory indefinitely and turn the limiter into a memory-DoS vector. A TTL-backed cache/store or periodic sweep would close that hole.

Also applies to: 341-356, 370-381

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.js` around lines 269 - 270, The adminRateLimitStore and
webhookRateLimitStore currently grow unbounded because keys are never deleted;
replace them with a TTL-backed cache or add an expiry wrapper plus a periodic
sweeper: either swap new Map() for a small dependency like node-cache or
lru-cache configured with a TTL, or keep the Map but store objects {count,
expiresAt} and add a setInterval cleanup that deletes expired keys; apply the
same change to the other rate-limit buckets used elsewhere in this file (the
other Map-based rate-limit stores) and ensure any increment/check logic (where
these stores are read or updated) uses the expiry semantics so stale entries get
removed or reused.

Comment on lines +309 to +316
const isHttpsRequest = (req) => {
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const proto = forwardedProto.split(',')[0] ?? '';
return proto.trim() === 'https';
}
return req.secure;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and understand the context around line 309-316
cd apps/api/src && wc -l index.js && echo "---" && sed -n '300,325p' index.js

Repository: Ryanakml/ChatFuse

Length of output: 1014


🏁 Script executed:

# Search for express app initialization and trust proxy configuration
rg "trust proxy" --type js -B 2 -A 2

Repository: Ryanakml/ChatFuse

Length of output: 359


🏁 Script executed:

# Check if there's any proxy configuration in the codebase
rg "app\.set\(" --type js -A 1 | head -50

Repository: Ryanakml/ChatFuse

Length of output: 216


🏁 Script executed:

# Look for any environment-specific or deployment configuration
find . -name "*.json" -o -name "*.env*" -o -name "*.config.*" | grep -E "(package|env|config)" | head -20

Repository: Ryanakml/ChatFuse

Length of output: 613


🏁 Script executed:

# Search for trustProxy variable definition
rg "trustProxy" --type js -B 5 -A 2

Repository: Ryanakml/ChatFuse

Length of output: 1156


🏁 Script executed:

# Check the .env.example to understand configuration
cat apps/api/.env.example

Repository: Ryanakml/ChatFuse

Length of output: 386


🏁 Script executed:

# Search for usage of isHttpsRequest function
rg "isHttpsRequest" --type js -B 2 -A 2

Repository: Ryanakml/ChatFuse

Length of output: 613


🏁 Script executed:

# Also check if there are any security-related tests or comments
rg "https|secure|proxy" --type js -i | grep -E "(test|spec|check)" | head -20

Repository: Ryanakml/ChatFuse

Length of output: 704


Use req.secure instead of reading X-Forwarded-Proto directly.

The current code bypasses Express's trust proxy security boundary by directly reading the X-Forwarded-Proto header, even when trust proxy is disabled. Since TRUST_PROXY defaults to false and is not configured in .env.example, untrusted clients can spoof the header with X-Forwarded-Proto: https to bypass the HTTPS check. Express only treats forwarded headers as safe when trust proxy is explicitly configured to own them.

Safer implementation
-    const isHttpsRequest = (req) => {
-        const forwardedProto = req.headers['x-forwarded-proto'];
-        if (typeof forwardedProto === 'string') {
-            const proto = forwardedProto.split(',')[0] ?? '';
-            return proto.trim() === 'https';
-        }
-        return req.secure;
-    };
+    const isHttpsRequest = (req) => req.secure;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isHttpsRequest = (req) => {
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const proto = forwardedProto.split(',')[0] ?? '';
return proto.trim() === 'https';
}
return req.secure;
};
const isHttpsRequest = (req) => req.secure;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.js` around lines 309 - 316, The isHttpsRequest helper
reads the X-Forwarded-Proto header directly (allowing spoofing when Express
trust proxy is not enabled); replace that logic so isHttpsRequest only relies on
req.secure and stop parsing req.headers['x-forwarded-proto'] directly. If you
actually intend to honor forwarded headers, configure Express trust proxy from
TRUST_PROXY (e.g. app.set('trust proxy', process.env.TRUST_PROXY)) and document
that in .env.example; otherwise remove the header parsing and return req.secure
in isHttpsRequest.

Comment on lines +397 to +399
app.get('/ready', (_req, res) => {
res.json({ ok: true });
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make /ready reflect webhook dependencies.

This endpoint stays green even when Redis/BullMQ are unavailable, so orchestration can keep routing traffic to an instance that cannot dedupe or enqueue webhook events. /health can stay shallow, but /ready should exercise the dependencies the POST /webhook path actually needs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.js` around lines 397 - 399, The /ready route currently
always returns OK; change the app.get('/ready', ...) handler to perform the same
dependency checks used by the POST '/webhook' path (e.g., verify Redis client
connectivity and BullMQ queue readiness or call the same helper used by webhook
enqueuing), and return 200 only if those checks succeed otherwise return 503
with an error body explaining which dependency failed; reuse existing functions
or clients referenced by the POST '/webhook' logic so readiness mirrors webhook
runtime requirements.

Comment on lines +326 to +331
setIfNotExists: async (key, ttlSeconds) => {
await ensureConnected();
const result = await redis.set(key, '1', {
EX: ttlSeconds,
NX: true,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/api/src/index.ts | sed -n '320,340p'

Repository: Ryanakml/ChatFuse

Length of output: 579


🏁 Script executed:

# Search for where setIfNotExists is called to understand how ttlSeconds is provided
rg -A 3 -B 3 "setIfNotExists" apps/api/src/

Repository: Ryanakml/ChatFuse

Length of output: 3214


🏁 Script executed:

# Check if there's any validation logic in the file or related files
rg "ttlSeconds|isInteger" apps/api/src/ -A 2 -B 2

Repository: Ryanakml/ChatFuse

Length of output: 1396


🏁 Script executed:

# Find where idempotencyTtlSeconds is defined or set
rg "idempotencyTtlSeconds" apps/api/src/ -B 5 -A 2

Repository: Ryanakml/ChatFuse

Length of output: 4574


🏁 Script executed:

# Check for any configuration or environment variable handling related to TTL
rg "TTL|ttl" apps/api/src/index.ts -B 2 -A 2

Repository: Ryanakml/ChatFuse

Length of output: 964


🏁 Script executed:

# Find parseNumber function definition
rg "function parseNumber|const parseNumber|export.*parseNumber" apps/api/src/ -A 5

Repository: Ryanakml/ChatFuse

Length of output: 654


🏁 Script executed:

# Also check imports at top of the file to see where parseNumber comes from
head -50 apps/api/src/index.ts | rg "import|parseNumber"

Repository: Ryanakml/ChatFuse

Length of output: 363


Validate TTL is a positive integer before passing to Redis.

ttlSeconds lacks validation. The parseNumber function only checks finiteness, allowing 0, negative values, and decimals. Additionally, direct injection via options.idempotencyTtlSeconds bypasses parseNumber entirely. Redis SET with EX rejects non-positive values at runtime and silently truncates decimals, breaking idempotency semantics. Guard this at the store boundary so both env-driven and injected callers are protected.

Suggested fix
    setIfNotExists: async (key, ttlSeconds) => {
+     if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
+       throw new Error(`Invalid idempotency TTL: ${ttlSeconds}`);
+     }
      await ensureConnected();
      const result = await redis.set(key, '1', {
        EX: ttlSeconds,
        NX: true,
      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setIfNotExists: async (key, ttlSeconds) => {
await ensureConnected();
const result = await redis.set(key, '1', {
EX: ttlSeconds,
NX: true,
});
setIfNotExists: async (key, ttlSeconds) => {
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new Error(`Invalid idempotency TTL: ${ttlSeconds}`);
}
await ensureConnected();
const result = await redis.set(key, '1', {
EX: ttlSeconds,
NX: true,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.ts` around lines 326 - 331, The setIfNotExists function
uses ttlSeconds without ensuring it is a positive integer; update the store
boundary (setIfNotExists) to validate and normalize ttlSeconds (from
options.idempotencyTtlSeconds or any caller) before calling redis.set: ensure
it's a finite integer > 0 (e.g., parseNumber result must be > 0 and you should
Math.floor/truncate decimals) and throw or return an explicit error when
invalid, so Redis SET EX never receives zero, negative, or non-integer values;
also update or centralize validation logic used by parseNumber to enforce this
rule.

Comment on lines +437 to +444
const isHttpsRequest = (req: express.Request) => {
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const proto = forwardedProto.split(',')[0] ?? '';
return proto.trim() === 'https';
}
return req.secure;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find the file and check the full context around the suspicious function
wc -l apps/api/src/index.ts

Repository: Ryanakml/ChatFuse

Length of output: 86


🏁 Script executed:

# Check the surrounding code in the file
sed -n '420,460p' apps/api/src/index.ts

Repository: Ryanakml/ChatFuse

Length of output: 1213


🏁 Script executed:

# Search for trust proxy configuration in Express setup
rg -i "trust.*proxy" apps/api/src/ -A 2 -B 2

Repository: Ryanakml/ChatFuse

Length of output: 1422


🏁 Script executed:

# Look for where isHttpsRequest is used
rg "isHttpsRequest" apps/api/src/ -A 3 -B 2

Repository: Ryanakml/ChatFuse

Length of output: 1357


🏁 Script executed:

# Check if there's any proxy configuration in the main entry point
rg "app\.(use|set)" apps/api/src/index.ts | head -20

Repository: Ryanakml/ChatFuse

Length of output: 153


🏁 Script executed:

# Search for any proxy-related middleware or header validation
rg "x-forwarded" apps/api/src/ -i

Repository: Ryanakml/ChatFuse

Length of output: 230


🏁 Script executed:

# Check if there's any other security validation for the forwarded headers
rg "req\.headers" apps/api/src/index.ts | head -20

Repository: Ryanakml/ChatFuse

Length of output: 121


🌐 Web query:

Express.js req.secure behavior trust proxy false x-forwarded-proto

💡 Result:

In Express, req.secure is essentially:

  • req.secure === (req.protocol === 'https')

And req.protocol behaves like this:

  • If trust proxy evaluates to false (default), Express does not trust/consult X-Forwarded-Proto; it determines the protocol from the direct connection to Node (i.e., whether the socket is TLS-encrypted). So if you’re behind a TLS-terminating reverse proxy / load balancer (client→proxy is HTTPS, proxy→Node is HTTP), then req.secure will be false even if the original request was HTTPS, even if X-Forwarded-Proto: https is present. [1]
  • If trust proxy is enabled, Express will use X-Forwarded-Proto (when present) to set req.protocol, which makes req.secure reflect the original client protocol. [1][2]

To make req.secure respect X-Forwarded-Proto, you must do both:

  1. Configure your proxy to set/overwrite X-Forwarded-Proto (and related X-Forwarded-* headers).
  2. Enable Express trust proxy appropriately, e.g.:
app.set('trust proxy', true); // or 1, or a specific subnet/IP list

Express warns that enabling trust proxy means you’re trusting headers that can be spoofed unless your last trusted proxy strips/overwrites them. [2]

Sources: Express req.protocol behavior with trust proxy and X-Forwarded-Proto. [1][2]; Express “behind proxies” guidance and security note. [2]

[1] (jiwon-park.github.io)
[2] (expressjs.com)

Citations:


🏁 Script executed:

# Check what happens in the middleware when isHttpsRequest returns false
sed -n '440,460p' apps/api/src/index.ts

Repository: Ryanakml/ChatFuse

Length of output: 496


🏁 Script executed:

# Find the middleware that uses isHttpsRequest
sed -n '450,480p' apps/api/src/index.ts

Repository: Ryanakml/ChatFuse

Length of output: 818


🏁 Script executed:

# Look for the complete HTTPS enforcement middleware
rg -A 10 "app.use.*isHttpsRequest" apps/api/src/index.ts

Repository: Ryanakml/ChatFuse

Length of output: 43


🏁 Script executed:

# Search more broadly for the middleware that uses isHttpsRequest
rg -B 2 -A 8 "isHttpsRequest" apps/api/src/index.ts

Repository: Ryanakml/ChatFuse

Length of output: 633


Don't read x-forwarded-proto directly—it bypasses the HTTPS enforcement gate.

A direct HTTP client can send X-Forwarded-Proto: https and pass the HTTPS check because this function reads the raw header regardless of the TRUST_PROXY setting. When TRUST_PROXY is false (the likely default), Express correctly ignores spoofed forwarded headers for req.secure, but this function circumvents that protection by checking the header first.

Use req.secure only, which respects the trust proxy configuration:

Suggested fix
-  const isHttpsRequest = (req: express.Request) => {
-    const forwardedProto = req.headers['x-forwarded-proto'];
-    if (typeof forwardedProto === 'string') {
-      const proto = forwardedProto.split(',')[0] ?? '';
-      return proto.trim() === 'https';
-    }
-    return req.secure;
-  };
+  const isHttpsRequest = (req: express.Request) => req.secure;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isHttpsRequest = (req: express.Request) => {
const forwardedProto = req.headers['x-forwarded-proto'];
if (typeof forwardedProto === 'string') {
const proto = forwardedProto.split(',')[0] ?? '';
return proto.trim() === 'https';
}
return req.secure;
};
const isHttpsRequest = (req: express.Request) => req.secure;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.ts` around lines 437 - 444, The isHttpsRequest helper
bypasses Express's trust-proxy protection by reading the raw 'x-forwarded-proto'
header; update isHttpsRequest to stop inspecting
req.headers['x-forwarded-proto'] and rely solely on req.secure (which respects
the app's trust proxy setting) so that spoofed headers cannot override HTTPS
enforcement (keep the function name isHttpsRequest and remove the manual header
parsing logic).

Comment on lines +548 to +550
app.get('/ready', (_req, res) => {
res.json({ ok: true });
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make /ready depend on Redis and BullMQ health.

This currently reports ready even if the process cannot reserve idempotency keys or enqueue jobs. That will route webhook traffic to an instance that is not actually able to serve it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.ts` around lines 548 - 550, The readiness endpoint
implemented in the app.get('/ready') handler must verify external dependencies
(Redis and BullMQ) before returning ok; modify the '/ready' route handler to
perform health checks against the Redis client used for idempotency key
reservation and the BullMQ Queue/QueueScheduler or Worker used for enqueuing
jobs (i.e., call their ping/health/status methods or attempt a lightweight
operation such as PING for Redis and getJobCounts/getRepeatableJobs or
queue.client.ping for BullMQ) and return a non-200 / { ok: false } response when
either check fails so the process is not marked ready if it cannot reserve
idempotency keys or enqueue jobs. Ensure the updated logic is in the same
app.get('/ready', ...) handler and logs/returns clear failure state.

Comment on lines +603 to +627
const firstSeen = await idempotencyStore.setIfNotExists(dedupeKey, idempotencyTtlSeconds);
if (!firstSeen) {
observability.onDuplicateHit(ingressContext, {
eventKey,
});
res.status(200).json({ ok: true });
return;
}

try {
await ingressQueue.enqueue({
eventKey,
payload: coerceJsonValue(payload),
receivedAt: new Date().toISOString(),
});
observability.onEnqueueSuccess(ingressContext, {
eventKey,
});
} catch {
await idempotencyStore.delete(dedupeKey);
observability.onEnqueueFailure(ingressContext, {
eventKey,
errorCode: 'ENQUEUE_FAILED',
});
sendWebhookError(res, 503, 'ENQUEUE_FAILED', 'Failed to enqueue webhook event');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep observability out of the enqueue rollback path.

A throw from onEnqueueSuccess() after queue.add() succeeds will land in this catch, delete the dedupe key, and turn a successfully queued webhook into a retried duplicate. setIfNotExists() is also outside the guarded block, so store failures skip the structured webhook error path entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/index.ts` around lines 603 - 627, The current flow calls
idempotencyStore.setIfNotExists outside any guard and runs
observability.onEnqueueSuccess inside the same try that rolls back the dedupe
key; fix by: 1) wrap idempotencyStore.setIfNotExists in its own try/catch and
call sendWebhookError on store failures so store errors follow the structured
error path; 2) perform ingressQueue.enqueue in a dedicated try/catch that only
deletes the dedupe key when the enqueue itself fails; and 3) move
observability.onEnqueueSuccess out of the enqueue failure path (or invoke it in
a separate try/catch that swallows/logs errors) so exceptions from
observability.onEnqueueSuccess cannot trigger idempotencyStore.delete and turn a
successful enqueue into a retried duplicate.

Comment on lines +25 to +27
if (publicRoutes.some((route) => pathname.startsWith(route))) {
return NextResponse.next();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Tighten public-route matching to avoid prefix auth bypass.

pathname.startsWith(route) makes /healthz and /health/... public too, which widens the unauthenticated surface beyond the declared route. Match the exact path, or an explicit subpath boundary, instead.

🔐 Suggested fix
-  if (publicRoutes.some((route) => pathname.startsWith(route))) {
+  if (
+    publicRoutes.some(
+      (route) => pathname === route || pathname.startsWith(`${route}/`),
+    )
+  ) {
     return NextResponse.next();
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (publicRoutes.some((route) => pathname.startsWith(route))) {
return NextResponse.next();
}
if (
publicRoutes.some(
(route) => pathname === route || pathname.startsWith(`${route}/`),
)
) {
return NextResponse.next();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/middleware.ts` around lines 25 - 27, The current public-route
check uses pathname.startsWith(route) which allows prefixes like "/health" to
match "/healthz"; update the logic in the middleware where publicRoutes and
pathname are checked (the block that currently returns NextResponse.next()) to
require either an exact match (pathname === route) or a proper subpath boundary
by checking pathname.startsWith(route + "/") for routes that should allow nested
paths; ensure both checks are applied for each entry in publicRoutes so only the
declared path or its explicit subpaths are treated as public.

@Ryanakml Ryanakml merged commit 51f1f9c into main Mar 6, 2026
3 checks passed
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