Skip to content

security: hardening pass across 7 packages#21

Merged
davidcrowe merged 4 commits intomainfrom
dev/security-review-hardening
Apr 14, 2026
Merged

security: hardening pass across 7 packages#21
davidcrowe merged 4 commits intomainfrom
dev/security-review-hardening

Conversation

@davidcrowe
Copy link
Copy Markdown
Owner

@davidcrowe davidcrowe commented Apr 14, 2026

Summary

Security review of the @gatewaystack/* packages uncovered 8 findings from critical to medium. This branch lands the fixes in two commits: a tiny chore that syncs identifiabl-core/package.json with the 0.1.1 version already published on npm, and a larger security commit with the hardening work.

All changes are either (a) deletions of unsafe behavior, (b) new gates (timing-safe compare, env-var requirement, mount-time check), or (c) hardening of detection/validation surface. No product features have been added to the open-source packages.

Findings

# Severity Package Finding Version bump
1 CRITICAL proxyabl-core SSRF guard was synchronous, did not resolve DNS. Rewritten async; resolves A/AAAA and checks every address against private ranges. Adds IPv4-mapped IPv6 handling. 0.0.4 → 0.1.0
2 CRITICAL explicabl (webhook) LOG_WEBHOOK_SECRET had a hardcoded default and the comparison was not timing-safe. Default removed, env var now required, crypto.timingSafeEqual used. 0.0.6 → 0.0.7
3 HIGH explicabl (health) /health/auth0 was unauthenticated, returned identity-provider config verbatim, and amplified outbound requests to upstream management APIs. Now gated behind HEALTH_ADMIN_SECRET; 404 when unset. (same)
4 HIGH limitabl-core Budget tracker and agent guard accepted negative/NaN/Infinity cost values — each with a distinct bypass or DoS. Added sanitizeCost helper, clamp-and-warn at three entry points. 0.1.0 → 0.1.1
5 HIGH limitabl + explicabl webhook Rate-limit key derivation read a client-supplied header directly, trivially forgeable. Now uses req.ip — operator must configure trust proxy. 0.1.0 → 0.2.0
6 HIGH transformabl-core PII detection scanned raw bytes only; zero-width-character insertion defeated the regex. New stripInvisible helper with position-preserving remap. 0.2.0 → 0.3.0
7 MEDIUM validatabl Middleware silently allowed all requests when configured with no checks. Factory now throws at mount time; decision() primitive is unchanged. 0.1.0 → 0.2.0
8 MEDIUM apps/gateway-server Demo-app middleware order had the per-identity rate limiter running before identity was established. Swapped to match the adjacent comment. (no publish)

Commit 50bde20 contains the detailed per-finding write-up with class-of-bug, affected code paths, and mitigation rationale. Intentional disclosure of exploit primitives lives there (and, separately, in GitHub Security Advisories — see below) rather than in this summary.

Related GitHub security posture

This PR intentionally does not bundle dependency updates, but reviewers should know:

Disclosure

Formal GitHub Security Advisories (with CVEs) should be filed for findings #1, #2, #3, and #5 after merge + publish. Without GHSAs, downstream consumers on old versions have no automated Dependabot signal to upgrade. Draft URL: https://github.com/davidcrowe/GatewayStack/security/advisories/new.

Follow-ups (not blocking this PR)

  • NFKC / homoglyph / base64-hex PII detection (transformabl-core)
  • TOCTOU DNS rebinding via socket-pinning undici agent (proxyabl-core)
  • Defense-in-depth SSRF on proxyabl/src/router.ts proxyHandler
  • Demo app: body-parser JSON depth, global error handler

Test plan

  • npm run build — full monorepo tsc build clean
  • npx vitest run — 159/159 tests pass (+ new regression tests in proxyabl-core/__tests__/security.test.ts for DNS rebinding, IPv4-mapped IPv6, and transformabl-core/__tests__/detect.test.ts for ZW-char bypass)
  • gitleaks pre-commit clean on all three commits
  • Reviewer: spot-check that assertUrlSafe is now called with await at every call site (only one internal caller: proxyabl-core/src/execute.ts)
  • Reviewer: confirm @gatewaystack/limitabl 0.2.0 release notes mention the trust proxy requirement loudly enough
  • Reviewer: confirm HEALTH_ADMIN_SECRET and LOG_WEBHOOK_SECRET are set in any downstream deployments before upgrading @gatewaystack/explicabl

Release sequencing (after merge)

Publish order matters because of transitive deps:

  1. limitabl-core 0.1.1
  2. validatabl-core 0.1.1
  3. transformabl-core 0.3.0
  4. proxyabl-core 0.1.0
  5. Middleware packages: limitabl 0.2.0, validatabl 0.2.0, transformabl 0.2.0, proxyabl 0.0.12
  6. explicabl 0.0.7

The ESM hotfix (type: module) was published to npm as 0.1.1 but the repo
package.json was never updated to match. This commit brings the repo
source of truth in line with what npm already serves.
Review uncovered 8 findings ranging from critical to medium. All are
addressed in this commit. Where the fix changes behavior visible to
consumers, the package version is bumped as a minor/major signal so
downstream projects do not auto-upgrade without opting in.

Findings and mitigations:

1. [CRITICAL] SSRF in @gatewaystack/proxyabl-core
   The existing assertUrlSafe was synchronous and checked hostname strings
   only — it self-documented that DNS resolution was the caller's
   responsibility, which meant an allowlisted hostname resolving to a
   private IP (e.g. 169.254.169.254 cloud metadata, 127.0.0.1 loopback)
   bypassed the check entirely. Rewritten as an async function that
   resolves every A/AAAA record via dns.lookup and rejects if any
   resolved address is private. Added IPv4-mapped-IPv6 detection
   (::ffff:127.0.0.1 and its compressed hex form ::ffff:7f00:1) so an
   attacker cannot reach loopback by wrapping it in IPv6 syntax. Strips
   URL brackets from IPv6 hostnames before checking. Retains all
   existing private-range checks. Residual TOCTOU between lookup and
   fetch is documented in source + README.
   Version: proxyabl-core 0.0.4 → 0.1.0 (breaking: sync → async).
   proxyabl 0.0.11 → 0.0.12 (dep bump, own API unchanged).

2. [CRITICAL] Default webhook secret + timing-unsafe compare
   (explicabl/src/webhooks/auth0LogWebhook.ts)
   LOG_WEBHOOK_SECRET defaulted to "dev-change-me" (hardcoded string in
   public source). Comparison used `===` which short-circuits on first
   mismatch. The webhook promotes DCR clients via Auth0 Management API,
   so this is an authorization-relevant surface. Removed the default,
   lazy-read env var in handler so module-load never throws, and switched
   to crypto.timingSafeEqual with explicit length check + Bearer prefix
   strip. Returns 503 not_configured if the secret is missing, to avoid
   silently accepting the default.

3. [HIGH] Health endpoint leaks identity infra + amplifies outbound
   (explicabl/src/health.ts)
   /health/auth0 was unauthenticated, returned issuer/audience/JWKS URLs
   verbatim, and fetched Auth0 Management API tokens on every call. Any
   internet caller could hammer it to exhaust Auth0 Management rate
   limits or inflate billing. Now gated behind HEALTH_ADMIN_SECRET
   (timing-safe compare, 404 when unset or auth fails so the endpoint
   is not discoverable by scans). Error fields narrowed to generic
   codes (unreachable, bad_status_NNN) so raw upstream errors no
   longer leak.
   Version: explicabl 0.0.6 → 0.0.7.

4. [HIGH] Rate-limit/budget bypass via poisoned numeric input
   (limitabl-core/src/budgetTracker.ts, agentGuard.ts)
   record() and check() accepted unconstrained numeric costs. Negative
   cost subtracted from the running sum (refund bypass); NaN poisoned
   the sum permanently (NaN > maxSpend is false, cap silently
   disabled); ±Infinity pinned the sum and caused user lockout. These
   values realistically flow in from upstream usage data (e.g. an LLM
   response's token count multiplied by a pricing table). Added
   sanitize.ts with sanitizeCost() that clamps non-finite / negative
   values to 0 and logs a warning. Applied at all three entry points:
   budgetTracker.check, budgetTracker.record, agentGuard.recordToolCall.
   Clamp-not-throw chosen for production safety so a buggy caller does
   not crash the request path.
   Version: limitabl-core 0.1.0 → 0.1.1.

5. [HIGH] X-Forwarded-For trusted without trust proxy
   (limitabl/src/index.ts, explicabl/src/webhooks/auth0LogWebhook.ts)
   Rate-limit key derivation read the leftmost X-Forwarded-For entry
   directly. Any caller can forge that value, so an attacker could (a)
   rotate the spoofed IP per request to avoid rate limits entirely, (b)
   forge a victim's IP to exhaust their bucket, or (c) inflate the
   in-memory key map to slow OOM. Removed the direct read; rely on
   Express's req.ip with the operator-configured trust proxy setting.
   Applied the same fix to the webhook's own rate-limit key function.
   README documents the trust proxy requirement.
   Version: limitabl 0.1.0 → 0.2.0 (behavior change).

6. [HIGH] PII detection bypass via zero-width characters
   (transformabl-core/src/detect.ts, new normalize.ts)
   detectPii scanned raw bytes only. An attacker could split PII with
   zero-width characters (e.g. `john\u200B@example.com`) — the regex
   breaks, nothing is detected, the LLM downstream ignores the ZW char
   and sees the original PII. Compliance-relying callers lost their
   redaction guarantee. Added stripInvisible() that removes U+200B-200D,
   U+2060, U+FEFF from scan input and builds a position map back to
   original-text offsets so redactPii still covers the entire obfuscated
   span including the invisible chars themselves. Match.value preserves
   the original form so mask-mode output is correctly sized. Fast path
   returns reference-equal string with null map when no invisible chars
   are present (zero overhead for normal content). NFKC normalization +
   base64/hex decode-and-rescan left as follow-ups.
   Version: transformabl-core 0.2.0 → 0.3.0; transformabl 0.1.0 → 0.2.0
   with dep bump to transformabl-core ^0.3.0.

7. [MEDIUM] validatabl() middleware silently allows when misconfigured
   (validatabl/src/middleware.ts)
   The underlying decision() primitive returns allowed: true when
   called with no checks configured — defensible as a neutral building
   block. But the middleware wrapper that inherits this behavior is
   marketed as deny-by-default, and an operator who calls
   validatabl({}) silently gets open access. Factory now throws at
   mount time if requiredPermissions, policies, and inputSchema are
   all empty/missing, with a clear error naming the three options.
   decision() itself is unchanged (preserves existing tests); its
   docstring gained a SECURITY note explaining the empty-config
   semantics and pointing at the middleware as the enforcing layer.
   Version: validatabl 0.1.0 → 0.2.0 (behavior change);
   validatabl-core 0.1.0 → 0.1.1 (docstring only).

8. [MEDIUM] Demo app middleware order bug
   (apps/gateway-server/src/app.ts)
   The /protected pipeline mounted `limitabl, identifiabl, transformabl`
   while the adjacent comment described the correct order
   `identifiabl, limitabl, transformabl`. With the code's order, the
   per-identity rate limiter runs before identity is established —
   falling back to IP keying, which means authenticated users sharing a
   NAT/proxy IP with unauthenticated traffic share a rate-limit bucket.
   Swapped the middlewares; no package publish needed (reference app
   only). Anyone reading the repo as a composition example now sees
   the correct order.

README updates for proxyabl-core, limitabl, validatabl, explicabl to
document the new APIs, env-var requirements, and behavior changes.

All 159 tests pass on this branch. Full monorepo build is clean.
Residual items (NFKC/homoglyph/base64 PII detection, TOCTOU DNS socket
pinning, defense-in-depth SSRF on proxyabl/src/router.ts, demo-app
body-parser depth + global error handler) are tracked as follow-ups.
Comment thread packages/transformabl-core/src/normalize.ts Fixed
Addresses CodeQL js/loop-bound-injection on the normalize.ts character
walk. Inputs larger than MAX_NORMALIZE_LENGTH (1M chars) bypass the
ZW-strip pass and return unchanged rather than truncating. PII detection
still runs on the full input — callers enforce size limits upstream.
…ss-rate-limit dep

Addresses CodeQL js/missing-rate-limiting on /health/auth0: even with
timing-safe secret compare, the handler needs per-caller throughput
limits to prevent brute-force of HEALTH_ADMIN_SECRET. 20 req/min matches
the operator-polling use case and makes brute force of a reasonable-
entropy secret infeasible.

Also declares express-rate-limit as an explicabl dependency — the sibling
auth0 webhook already imported it but the package only had express in
its manifest (worked locally via hoisting, would fail a clean install).
@davidcrowe davidcrowe merged commit f0322e5 into main Apr 14, 2026
6 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.

2 participants