Skip to content

fix: bind emulator server to localhost by default - #21

Merged
gjtorikian merged 3 commits into
mainfrom
nickcollisson/sec-1552-emulate-bind-localhost
Jul 23, 2026
Merged

fix: bind emulator server to localhost by default#21
gjtorikian merged 3 commits into
mainfrom
nickcollisson/sec-1552-emulate-bind-localhost

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Summary

Automated first-pass security fix for SEC-1552requires human security review before merge.

createEmulator called serve() without a hostname, so @hono/node-server fell through to Node's server.listen(port), which binds to all interfaces (0.0.0.0/::). That exposed the emulator's intentionally-unauthenticated endpoints (/user_management/authorize, /user_management/authenticate, /_emulate/hooks, /sso/*) and the well-known sk_test_default key to any host on the same network (shared Wi-Fi, office LAN, CI/K8s pod network) — directly contradicting the README's claim that it "binds to localhost".

Fix: default the bind address to loopback and add an explicit opt-in for the (rare) case where network exposure is intended.

- const httpServer = serve({ fetch: app.fetch, port });
+ const hostname = options.hostname ?? '127.0.0.1';
+ // explicit hostname makes listen() async → await the listening callback
+ const httpServer = await new Promise((resolve) => {
+   const server = serve({ fetch: app.fetch, port, hostname }, () => resolve(server));
+ });
  • EmulatorOptions.hostname?: string — programmatic opt-in (createEmulator({ hostname: '0.0.0.0' })).
  • --host <hostname> CLI flag (default localhost) for workos-emulate.
  • README Network Security section updated to describe the actual behavior + the opt-in.

Notes for reviewers:

  • Default is 127.0.0.1 (IPv4 loopback) rather than the string localhost: Node's server.listen(port, 'localhost') resolved to ::1 on the test host while fetch('http://localhost') resolved to 127.0.0.1, making the emulator's own advertised url unreachable. 127.0.0.1 matches how fetch/SDKs resolve localhost here and keeps the advertised http://localhost:<port> URL working. IPv6-only loopback (http://[::1]) callers can use --host ::1.
  • Passing a hostname makes listen() asynchronous, so the actual bound port wasn't ready when httpServer.address() was read — breaking port: 0. The fix awaits the listening callback. Full suite (port: 0 heavy) stays green.

Testing

  • New src/bind-hostname.spec.ts (attacker-perspective e2e over real HTTP): default bind is not reachable on the host's non-loopback IPv4 address, loopback works, and hostname: '0.0.0.0' makes the non-loopback address reachable (proves the opt-in).
  • Manual repro against the vulnerable revision (pre-fix): from a non-loopback address, unauthenticated authorizeauthenticate minted a valid RS256 access token + refresh token for a seeded user; /_emulate/hooks accepted response forgery; sk_test_default registered a webhook to 169.254.169.254 (blind SSRF). After the fix, the same non-loopback requests are refused (ECONNREFUSED) while loopback continues to work.
  • npm run lint, npm run typecheck, npm run fmt:check, and npx vitest run (515 tests) all pass.

Link to Devin session: https://app.devin.ai/sessions/361b8d32087c41bcbb1c1d74bc02613c

The emulator's HTTP server called serve() without a hostname, so @hono/node-server bound to all interfaces (0.0.0.0/::), exposing its unauthenticated token-minting, response-forgery, and control endpoints to any host on the same network — contradicting the README's claim that it binds to localhost.

Default the bind address to 127.0.0.1 and add an opt-in hostname option (createEmulator) and --host CLI flag for intentional network exposure. Await the listening callback so the resolved port is available (an explicit hostname makes listen() asynchronous), keeping port: 0 working.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author
Original prompt from Linear User

Please work on ticket "Emulator HTTP server binds to all network interfaces by default (docs claim localhost-only), exposing unauthenticated token-minting and API response-forgery endpoints to adjacent networks" (SEC-1552)

@playbook:playbook-b588614117c7477a9b9729928385384f

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

SEC-1552

@devin-ai-integration devin-ai-integration Bot changed the title emulate: Bind server to localhost by default fix: bind emulator server to localhost by default Jul 22, 2026
@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a real security exposure: the emulator previously called serve() without a hostname, causing Node to bind to all interfaces (0.0.0.0), making its unauthenticated token-minting endpoints reachable from any host on the local network. The fix defaults the bind address to 127.0.0.1 and adds a hostname opt-in for intentional network exposure.

  • Core fix (src/index.ts): a listen() helper wraps @hono/node-server's serve() with an explicit hostname, properly awaits the async listening callback (required for port: 0), and rejects on bind failure. A secondary best-effort ::1 bind is added so that the advertised localhost URL works regardless of which address family the resolver returns.
  • CLI (src/cli.ts): a new --host flag surfaces the hostname option, with an empty-string guard preventing a bare --host= from silently re-exposing the server.
  • Tests (src/bind-hostname.spec.ts): attacker-perspective e2e tests confirm the loopback-only default and validate the hostname: '0.0.0.0' opt-in, with appropriate it.skipIf guards for environments lacking an external IP or IPv6 loopback.

Confidence Score: 5/5

Safe to merge — the fix correctly restricts the emulator to loopback-only by default, with a well-guarded explicit opt-in for network exposure.

All changed paths are straightforward and well-tested. The dual-stack loopback approach cleanly handles the localhost resolution ambiguity without any non-loopback exposure. Previous review concerns about error handling and IPv6 test guards have been addressed.

No files require special attention.

Important Files Changed

Filename Overview
src/index.ts Core security fix: binds to 127.0.0.1 by default, adds best-effort ::1 secondary bind, and wraps listen in an awaitable promise with proper error rejection.
src/cli.ts Adds --host CLI flag with proper validation (empty-string guard, missing-value check, --host=value form).
src/bind-hostname.spec.ts New attacker-perspective e2e tests with appropriate it.skipIf guards for environments without an external IP or IPv6 loopback.
README.md Documentation updated to accurately describe loopback-default behavior and the opt-in flag for network exposure.

Reviews (3): Last reviewed commit: "Guard IPv6 loopback test and reject empt..." | Re-trigger Greptile

Comment thread src/index.ts Outdated
Comment thread src/index.ts Outdated
Address review feedback: keep the advertised localhost URL (used as the JWT issuer) reachable on dual-stack hosts by also listening on ::1 when using the default loopback, and reject the listen promise on bind errors instead of hanging.
Comment thread src/bind-hostname.spec.ts Outdated
Skip the IPv6-loopback assertion when the host has no IPv6 loopback (the secondary ::1 listener is best-effort), so it doesn't hard-fail on IPv6-less CI. Reject an empty --host= value and treat a falsy hostname as the loopback default, so a stray flag can't silently re-enable all-interface binding.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Also addressed the empty---host= gap flagged in the review summary (4502b32): the --host= (equals) form now rejects an empty value just like the space form, and createEmulator treats a falsy hostname as the 127.0.0.1 loopback default (options.hostname || '127.0.0.1') — so a stray --host= can no longer pass '' and silently re-enable all-interface binding.

@gjtorikian
gjtorikian merged commit 5a0fd1b into main Jul 23, 2026
6 checks passed
@gjtorikian
gjtorikian deleted the nickcollisson/sec-1552-emulate-bind-localhost branch July 23, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant