fix: bind emulator server to localhost by default - #21
Conversation
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.
Original prompt from Linear User
|
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Greptile SummaryThis PR fixes a real security exposure: the emulator previously called
Confidence Score: 5/5Safe 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
Reviews (3): Last reviewed commit: "Guard IPv6 loopback test and reject empt..." | Re-trigger Greptile |
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.
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.
|
Also addressed the empty- |
Summary
Automated first-pass security fix for SEC-1552 — requires human security review before merge.
createEmulatorcalledserve()without ahostname, so@hono/node-serverfell through to Node'sserver.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-knownsk_test_defaultkey 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 tolocalhost".Fix: default the bind address to loopback and add an explicit opt-in for the (rare) case where network exposure is intended.
EmulatorOptions.hostname?: string— programmatic opt-in (createEmulator({ hostname: '0.0.0.0' })).--host <hostname>CLI flag (defaultlocalhost) forworkos-emulate.Notes for reviewers:
127.0.0.1(IPv4 loopback) rather than the stringlocalhost: Node'sserver.listen(port, 'localhost')resolved to::1on the test host whilefetch('http://localhost')resolved to127.0.0.1, making the emulator's own advertisedurlunreachable.127.0.0.1matches howfetch/SDKs resolvelocalhosthere and keeps the advertisedhttp://localhost:<port>URL working. IPv6-only loopback (http://[::1]) callers can use--host ::1.hostnamemakeslisten()asynchronous, so the actual bound port wasn't ready whenhttpServer.address()was read — breakingport: 0. The fix awaits the listening callback. Full suite (port: 0heavy) stays green.Testing
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, andhostname: '0.0.0.0'makes the non-loopback address reachable (proves the opt-in).authorize→authenticateminted a valid RS256 access token + refresh token for a seeded user;/_emulate/hooksaccepted response forgery;sk_test_defaultregistered a webhook to169.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, andnpx vitest run(515 tests) all pass.Link to Devin session: https://app.devin.ai/sessions/361b8d32087c41bcbb1c1d74bc02613c