iMessage: harden echo detection against control-char prefixes#59386
iMessage: harden echo detection against control-char prefixes#59386Jeevan-ZGDX wants to merge 3 commits into
Conversation
- Strip leading/trailing control characters from echo text normalization - Add test for control char stripping in echo cache - Fixes issue with reflected payloads containing \u0000 etc.
Greptile SummaryThis PR bundles two distinct areas of work: (1) the stated bug fix — hardening iMessage echo detection against control-character prefixes in reflected messages — and (2) a sizeable security hardening layer added to the gateway (CORS enforcement, WS/SSE connection caps, idle timeouts, a new SSE events endpoint, and localhost-default bindings for the Vite dev server). Key changes:
Confidence Score: 2/5
|
| const sseClients = new Set<SseClient>(); | ||
| const perIpConnections = new Map<string, number>(); | ||
| const rateLimitTimestamps = new Map<string, number[]>(); |
There was a problem hiding this comment.
Module-level
rateLimitTimestamps map is never pruned for inactive IPs
rateLimitTimestamps is a module-level Map that accumulates one entry per unique client IP. On every call to checkRateLimit, the per-IP array is filtered down to recent timestamps and stored back:
rateLimitTimestamps.set(ip, recent);However, when all timestamps for an IP fall outside the window, recent becomes an empty array and the entry is stored back as [] rather than deleted. For any IP that makes even a single POST and then goes silent, a [] entry remains in the map forever. In a public deployment with many different source IPs (or a scanner sweep), this is an unbounded memory leak.
After the filter, delete the entry when recent is empty instead of setting it back:
if (recent.length === 0) {
rateLimitTimestamps.delete(ip);
} else {
rateLimitTimestamps.set(ip, recent);
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server/events-http.ts
Line: 57-59
Comment:
**Module-level `rateLimitTimestamps` map is never pruned for inactive IPs**
`rateLimitTimestamps` is a module-level `Map` that accumulates one entry per unique client IP. On every call to `checkRateLimit`, the per-IP array is filtered down to recent timestamps and stored back:
```ts
rateLimitTimestamps.set(ip, recent);
```
However, when all timestamps for an IP fall outside the window, `recent` becomes an empty array and the entry is **stored back as `[]`** rather than deleted. For any IP that makes even a single POST and then goes silent, a `[]` entry remains in the map forever. In a public deployment with many different source IPs (or a scanner sweep), this is an unbounded memory leak.
After the `filter`, delete the entry when `recent` is empty instead of setting it back:
```ts
if (recent.length === 0) {
rateLimitTimestamps.delete(ip);
} else {
rateLimitTimestamps.set(ip, recent);
}
```
How can I resolve this? If you propose a fix, please make it concise.| export async function handleEventsHttpRequest( | ||
| req: IncomingMessage, | ||
| res: ServerResponse, | ||
| opts: EventsHttpOptions = {}, | ||
| ): Promise<boolean> { | ||
| const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); | ||
| const pathname = url.pathname; | ||
|
|
||
| if (pathname === EVENTS_POST_PATH) { | ||
| if (req.method !== "POST") { | ||
| sendMethodNotAllowed(res, "POST"); | ||
| return true; | ||
| } | ||
| const ip = resolveIp(req, opts); | ||
| const limit = checkRateLimit(ip); | ||
| if (!limit.allowed) { | ||
| if (limit.retryAfter && limit.retryAfter > 0) { | ||
| res.setHeader("Retry-After", String(limit.retryAfter)); | ||
| } | ||
| sendJson(res, 429, { error: { message: "Too Many Requests", type: "rate_limited" } }); | ||
| return true; | ||
| } | ||
| const body = await readJsonBody(req, EVENT_BODY_MAX_BYTES); | ||
| if (!body.ok) { | ||
| const status = | ||
| body.error === "payload too large" | ||
| ? 413 | ||
| : body.error === "request body timeout" | ||
| ? 408 | ||
| : 400; | ||
| sendJson(res, status, { ok: false, error: body.error }); | ||
| return true; | ||
| } | ||
| broadcast("event", body.value); | ||
| sendJson(res, 202, { ok: true }); | ||
| return true; |
There was a problem hiding this comment.
Event endpoints lack authentication
handleEventsHttpRequest performs no credential check. Every other handler in server-http.ts that exposes sensitive functionality receives a resolved-auth parameter and enforces it internally (e.g. handleToolsInvokeHttpRequest), but the events handler is invoked without one.
As a result, any unauthenticated client that can reach the gateway can:
- Open a persistent SSE connection on
/api/events/streamand receive all events. - POST arbitrary JSON to
/api/events, which thebroadcast()function forwards verbatim to every connected SSE subscriber.
The second point is particularly impactful: any network-reachable caller can inject crafted events into the UI of every connected user. This matters most when the gateway is configured for non-loopback exposure (which this PR explicitly documents).
The handler should accept and enforce the gateway auth configuration the same way the other route handlers do.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server/events-http.ts
Line: 170-205
Comment:
**Event endpoints lack authentication**
`handleEventsHttpRequest` performs no credential check. Every other handler in `server-http.ts` that exposes sensitive functionality receives a resolved-auth parameter and enforces it internally (e.g. `handleToolsInvokeHttpRequest`), but the events handler is invoked without one.
As a result, any unauthenticated client that can reach the gateway can:
- Open a persistent SSE connection on `/api/events/stream` and receive all events.
- POST arbitrary JSON to `/api/events`, which the `broadcast()` function forwards verbatim to every connected SSE subscriber.
The second point is particularly impactful: any network-reachable caller can inject crafted events into the UI of every connected user. This matters most when the gateway is configured for non-loopback exposure (which this PR explicitly documents).
The handler should accept and enforce the gateway auth configuration the same way the other route handlers do.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a02b3dd50
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const perIpCounts = new Map<string, number>(); | ||
|
|
||
| wss.on("connection", (socket, upgradeReq) => { | ||
| if (clients.size >= maxConnections) { |
There was a problem hiding this comment.
Count pending sockets in global WebSocket connection limit
Use wss.clients.size (or another socket-level counter) for the global cap instead of clients.size. clients is only populated after a successful connect handshake (via setClient), so unauthenticated/pending sockets are not counted; an attacker can open many concurrent handshakes (especially across multiple IPs) and bypass MAX_WS_CONNECTIONS, defeating the overload protection this change intends to add.
Useful? React with 👍 / 👎.
| host: true, | ||
| port: 5173, | ||
| host: (() => { | ||
| const raw = process.env.HOSTNAME?.trim(); |
There was a problem hiding this comment.
Avoid HOSTNAME as the default Vite bind source
Reading process.env.HOSTNAME here makes the fallback to loopback effectively unreachable in many real environments (Linux shells/containers usually export HOSTNAME automatically), so the dev server can bind to an unintended interface/hostname instead of localhost and may even fail to bind if that hostname is not a local listen address. This regresses the stated localhost-by-default behavior and can break local development unexpectedly.
Useful? React with 👍 / 👎.
| const raw = | ||
| process.env.OPENCLAW_CORS_ALLOWED_ORIGINS?.trim() || | ||
| process.env.CORS_ALLOWED_ORIGINS?.trim() || | ||
| "http://localhost:18790"; |
There was a problem hiding this comment.
Align default CORS origin with the default control UI URL
The default CORS allowlist is hardcoded to http://localhost:18790, but the new Vite default host path serves from 127.0.0.1:18790 when no explicit host override is provided. When the UI is opened at that default URL, browser requests with Origin: http://127.0.0.1:18790 will not receive Access-Control-Allow-Origin, so cross-origin API calls are blocked despite both sides using defaults.
Useful? React with 👍 / 👎.
This comment was marked as spam.
This comment was marked as spam.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 682fbebaf2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const requestAuth = await authorizeGatewayHttpRequestOrReply({ | ||
| req, | ||
| res, | ||
| auth: opts.auth, | ||
| trustedProxies: opts.trustedProxies, | ||
| allowRealIpFallback: opts.allowRealIpFallback, | ||
| }); | ||
| if (!requestAuth) { | ||
| return true; |
There was a problem hiding this comment.
Check /api/events path before enforcing auth
handleEventsHttpRequest authenticates the request before it checks whether the path is /api/events or /api/events/stream, and this handler is invoked for every HTTP request from createGatewayHttpServer; when gateway auth is enabled, unrelated unauthenticated routes (for example Control UI/static routes or public plugin routes) will be short-circuited with a 401 here instead of falling through to their own handlers.
Useful? React with 👍 / 👎.
| res.statusCode = 404; | ||
| res.setHeader("Content-Type", "text/plain; charset=utf-8"); | ||
| res.end("Not Found"); |
There was a problem hiding this comment.
Restore gateway probe routes before 404 fallback
The request pipeline no longer dispatches /health, /healthz, /ready, or /readyz, so those probe URLs now fall through to the generic 404 response; this breaks liveness/readiness checks that previously succeeded and are used by deployment/monitoring paths.
Useful? React with 👍 / 👎.
| if (openResponsesEnabled) { | ||
| if ( | ||
| await handleOpenResponsesHttpRequest(req, res, { | ||
| auth: resolvedAuth, | ||
| config: openResponsesConfig, |
There was a problem hiding this comment.
Route /v1/models and /v1/embeddings in HTTP handler
The compat routing block now jumps directly to OpenResponses/chat-completions handling, with no call to the models or embeddings HTTP handlers, so /v1/models and /v1/embeddings requests will return 404 even when compat endpoints are enabled, breaking OpenAI-compatible clients that rely on those routes.
Useful? React with 👍 / 👎.
| const runId = dispatchAgentHook({ | ||
| ...normalized.value, | ||
| idempotencyKey, | ||
| sessionKey: normalizedDispatchSessionKey, | ||
| agentId: targetAgentId, | ||
| externalContentSource: "webhook", | ||
| sessionKey: sessionKey.value, | ||
| agentId: resolveHookTargetAgentId(hooksConfig, normalized.value.agentId), | ||
| }); |
There was a problem hiding this comment.
Normalize hook session key to target agent namespace
When dispatching /hooks/agent, the code now forwards sessionKey.value directly; if a request targets one agent but supplies an agent:<other>:... session key, the run is scheduled under the mismatched agent-scoped namespace instead of being rebound to the target agent, which can mix isolation boundaries between agents.
Useful? React with 👍 / 👎.
| if (client?.connect?.role === "node") { | ||
| const context = buildRequestContext(); | ||
| const nodeId = context.nodeRegistry.unregister(connId); |
There was a problem hiding this comment.
Unsubscribe session listeners on every WS disconnect
Disconnect cleanup now performs registry teardown only for role === "node"; non-node clients that subscribed to session/session-message events are no longer removed on close, leaving stale connection IDs in subscriber registries and causing unbounded registry growth over time unless clients explicitly unsubscribe before disconnecting.
Useful? React with 👍 / 👎.
|
Closing this as duplicate or superseded after Codex automated review. Close #59386 as superseded. The linked echo-loop issue #59363 was later closed from main-branch self-chat fixes, and the remaining prefix-normalization work is now tracked by narrower open iMessage echo-cache PRs, especially #62191 for U+FFFD/C0/C1 control-prefix normalization and #63581 for NUL prefixes. This PR is not a good canonical vehicle because it bundles unrelated gateway/CORS/SSE/WebSocket changes and the submitted control-character regex is syntactically invalid as written. Best possible solution: Close #59386 as superseded and continue review on a focused iMessage echo-cache normalization PR, preferably #62191 or a maintainer-created successor that folds in the NUL and attributed-prefix coverage from #63581/#66169. Keep gateway hardening as separate reviewed work, not bundled with this echo fix. What I checked:
So I’m closing this here and keeping the remaining discussion on the canonical linked item. Codex Review notes: model gpt-5.5, reasoning high; reviewed against 4ad8b613c9b6. |
Summary
Problem: iMessage assistant outbound messages were being reflected back as inbound user messages, causing infinite echo loops in direct chats.
Root Cause: Echo detection failed to match reflected messages containing leading control characters (e.g., \u0000) prefixed to the original text.
Fix: Enhanced text normalization in echo cache to strip leading/trailing control characters, ensuring detection works even with garbled reflections.
Impact: Prevents feedback loops without affecting normal conversation flow.
-Why it matters: Echo loops waste API tokens, cause agent confusion, and disrupt user workflows by generating unwanted responses.
-What changed: Modified normalizeEchoTextKey in echo-cache.ts to remove control characters (\u0000-\u001F, \u007F-\u009F) from text before comparison. Added unit test for control character stripping.
-What did NOT change (scope boundary): No changes to message routing logic, permission checks, or other channels. Echo detection remains scoped to iMessage only.
Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause / Regression History (if applicable)
Root cause: Echo cache normalization did not account for control characters added by iMessage reflection, causing text mismatch and allowing echoes to pass through.
Missing detection / guardrail: No stripping of non-whitespace control characters in text normalization.
Prior context: Issue started with version 2026.3.31, likely due to changes in iMessage handling or reflection behavior.
Why this regressed now: Unknown, but control-char prefixes in reflections were not anticipated in original echo logic.
What was ruled out: Not a change in is_from_me detection (code already drops all self-messages); confirmed as echo detection gap.
Regression Test Plan (if applicable)
User-visible / Behavior Changes
Diagram (if applicable)
N/A
Security Impact (required)
No)No)No)No)No)Repro + Verification
Environment
Steps
Expected : Agent does not see its own messages; smooth conversation.
Actual : Echo loop with agent replying to self.
Evidence : Session logs show self-addressed routes and control-char prefixed reflections. Local hotfix (force-drop is_from_me) resolved it.
Human Verification (required)
What you personally verified (not just CI), and how:
Verified scenarios:
Code compiles without errors.
Unit tests pass for echo cache normalization.
Logic review confirms control char stripping matches issue description.
Edge cases checked:
Empty strings, strings with only control chars, mixed content.
What you did not verify:
Full iMessage integration test (no macOS/iMessage environment available).
End-to-end echo loop reproduction.
Review Conversations
N/A (new PR)
Compatibility / Migration
Yes)No)No)Risks and Mitigations
None. This is a targeted fix to text normalization with no side effects on other logic.