Skip to content

Secure Electric shape proxy: default-deny tables + validate client where#4549

Open
KyleAMathews wants to merge 2 commits into
mainfrom
fix-electric-proxy-access-control
Open

Secure Electric shape proxy: default-deny tables + validate client where#4549
KyleAMathews wants to merge 2 commits into
mainfrom
fix-electric-proxy-access-control

Conversation

@KyleAMathews

Copy link
Copy Markdown
Contributor

Summary

Hardens the agents-server Electric shape proxy (/_electric/electric/v1/shape) against two access-control bypasses. Previously, any authenticated principal could read any table — and sidestep per-tenant/per-principal row scoping — through the proxy. Now unscoped tables are denied and client where clauses that could escape the enforced scope are rejected.

Root Cause

The proxy injects the privileged Electric API secret into every /v1/shape request, then applied scoping only via an if/else-if chain over ~9 known tables. Two gaps:

  1. Default-allow fall-through. Any table not in the chain fell through to return target — forwarded upstream with the secret attached and no row/column filter. A low-privilege principal could request table=entity_permission_grants, subscription_webhooks, etc. and read the whole table (cross-tenant in a shared deployment).
  2. where-clause break-out. Scoping was combined as enforced AND (clientWhere). Since the client controls the text inside the parens and SQL AND binds tighter than OR, a payload like 1=1) OR (1=1 collapses to (enforced AND (1=1)) OR (1=1) → always true, defeating the scoping.

Approach

  • Default-deny: the table if/else-if now ends in an else that throws ElectricProxyError(403, TABLE_NOT_ALLOWED). Only the explicitly column+row-scoped tables (the exact set the UI, mobile, and runtime sync) can be proxied; a missing table is also rejected. Verified no legitimate consumer requests a table outside this set (wake_registrations connects to Electric directly, not via the proxy).
  • where validation: isSelfContainedWhereClause rejects (400 INVALID_WHERE) any client clause whose parentheses dip below the top level or are unbalanced, has unterminated '/" literals, or contains -- / /* */ comment markers — a single-pass scanner that ignores characters inside string/identifier literals (with ''/"" escapes).
  • Wiring: ElectricProxyError lives in server-utils.ts (kept free of the heavy entity-manager import graph) and is mapped to an HTTP response by the global errorMapper, which now also logs rejections for intrusion detection.

Key Invariants

  • Every proxied /v1/shape request resolves to an explicitly scoped table, or it is rejected before forwarding (the secret is never attached to an unscoped request that reaches upstream).
  • A client where that passes validation cannot break out of the (...) group it is wrapped in — so enforced AND (client) always keeps enforced in force.
  • The validator only ever over-rejects (fail-safe); it never lets an unsafe clause through. Dollar-quoted / E'' strings are intentionally unmodeled and fall into over-rejection.

Non-goals

  • Not converting the (pre-existing) if/else-if table chain to a switch — out of scope, would bury the security change.
  • Not parsing/validating the semantics of the client where beyond structural self-containment; the upstream Postgres parser remains the authority on validity.
  • Not constant-time secret comparison or other unrelated proxy hardening.

Trade-offs

A structural scanner (vs. a full SQL parser) is simpler and safe because the only way to escape the wrapping parens is an unbalanced top-level ), which the depth check catches; comment markers are rejected outright to avoid the trailing-paren footgun. Reusing ElectricAgentsError was rejected to avoid pulling the heavy entity-manager graph into a utils module (keeps the unit test fast); the class instead mirrors its shape and is mapped uniformly in errorMapper.

Verification

cd packages/agents-server
pnpm vitest run test/server-utils.test.ts test/routing-hooks.test.ts
# 32 passed — default-deny + missing table, break-out payloads (balanced
# 1=1) OR (1=1 and depth-underflow a=1) OR (b=2), trailing comment, unterminated
# '/" literals, allowed cases (OR-wrapping, parens/comments inside literals, ''
# escapes, double-quoted identifiers), and the 403/400 errorMapper mapping.

Files changed

  • packages/agents-server/src/utils/server-utils.tsElectricProxyError class, isSelfContainedWhereClause validator, default-deny branch + where validation in buildElectricProxyTarget.
  • packages/agents-server/src/routing/hooks.tserrorMapper maps ElectricProxyError to an HTTP response and logs the rejection.
  • packages/agents-server/test/server-utils.test.ts, test/routing-hooks.test.ts — security test coverage.

🤖 Generated with Claude Code

KyleAMathews and others added 2 commits June 9, 2026 13:20
The /_electric/electric/v1/shape proxy injected the privileged Electric
secret into every request, then scoped only a fixed set of tables; any
other table fell through and was forwarded with the secret and no row/
column filter. It also AND-combined the client `where` as
`enforced AND (clientWhere)`, which a payload like `1=1) OR (1=1` could
break out of under SQL precedence, defeating per-tenant/per-principal
scoping.

- Default-deny: reject (403 TABLE_NOT_ALLOWED) any table not in the
  explicitly scoped allowlist, or a missing table param.
- Reject (400 INVALID_WHERE) client where clauses that are not
  self-contained: unbalanced parens, top-level paren underflow,
  unterminated string/identifier literals, or SQL comment markers.
- Map ElectricProxyError to an HTTP response in errorMapper and log
  rejections for intrusion detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.36842% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 74.13%. Comparing base (916f6cd) to head (1b1f9a1).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/agents-server/src/utils/server-utils.ts 97.14% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #4549       +/-   ##
===========================================
+ Coverage   54.80%   74.13%   +19.33%     
===========================================
  Files         317       52      -265     
  Lines       36681     6933    -29748     
  Branches    10466     2204     -8262     
===========================================
- Hits        20104     5140    -14964     
+ Misses      16544     1777    -14767     
+ Partials       33       16       -17     
Flag Coverage Δ
packages/agents ?
packages/agents-mobile ?
packages/agents-runtime ?
packages/agents-server 74.13% <97.36%> (+0.18%) ⬆️
packages/agents-server-ui ?
packages/electric-ax ?
typescript 74.13% <97.36%> (+19.33%) ⬆️
unit-tests 74.13% <97.36%> (+19.33%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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