Skip to content

fix(session): harden daemon HTTP requests#329

Merged
benvinegar merged 2 commits into
mainfrom
fix/session-daemon-http-hardening
May 19, 2026
Merged

fix(session): harden daemon HTTP requests#329
benvinegar merged 2 commits into
mainfrom
fix/session-daemon-http-hardening

Conversation

@benvinegar
Copy link
Copy Markdown
Member

Summary

  • Validate session daemon Host headers before routing HTTP or websocket requests.
  • Reject non-local/wrong-port Origin headers for browser-originated HTTP and websocket traffic.
  • Require application/json for session and raw broker API POST bodies.

Tests

  • bun run format:check
  • bun run typecheck
  • bun run lint
  • bun test src/session-broker/brokerServer.test.ts packages/session-broker/src/daemon.test.ts packages/session-broker-bun/src/serve.test.ts packages/session-broker-node/src/serve.test.ts
  • bun test ./src ./packages ./scripts ./test/cli ./test/session

This PR description was generated by Pi using OpenAI GPT-5

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 19, 2026

Greptile Summary

This PR hardens the local session daemon against cross-origin browser requests by adding Host and Origin header validation to all HTTP and WebSocket upgrade paths, and enforces application/json content-type for POST bodies in both the Hunk-specific session API and the shared broker daemon package.

  • brokerServer.ts gains validateHostHeader and validateOriginHeader middleware that runs before any route dispatch, with parseHostAndPort correctly handling bracketed IPv6, bare IPv4, and port-less forms; requests from non-loopback or wrong-port origins are rejected with 403.
  • daemon.ts adds a symmetrical 415 guard in handleApiRequest, but also inserts an identical hasJsonContentType throw inside the private parseJsonRequest helper — a check that is unreachable from the only call site and would surface as a 400 rather than 415 if ever reached.
  • Integration tests cover all three rejection paths (Host, Origin, Content-Type) with both HTTP and raw-TCP WebSocket scenarios.

Confidence Score: 4/5

The security hardening is correctly layered and well-tested; the two observations are minor edge cases that do not affect normal clients or browser-based DNS-rebinding attacks.

The Host/Origin/content-type guards work correctly for their intended threat model. The duplicate content-type check inside parseJsonRequest is dead code that could mislead future maintainers, and the unconditional pass for port-less Host headers slightly weakens the port guard for crafted non-browser clients — but neither affects real-world browser-based attack scenarios.

The parseHostAndPort + isAllowedHostPort logic in brokerServer.ts and the redundant guard in daemon.ts parseJsonRequest are the two spots worth a second look before merge.

Important Files Changed

Filename Overview
src/session-broker/brokerServer.ts Adds Host and Origin validation middleware applied before all routing, plus a JSON content-type guard for session API POSTs; port-less Host headers bypass port number validation in isAllowedHostPort.
packages/session-broker/src/daemon.ts Adds JSON content-type guard in both handleApiRequest (415 return) and parseJsonRequest (throw); the check inside parseJsonRequest is dead code since the outer guard fires first.
src/session-broker/brokerServer.test.ts Adds integration tests for Host, Origin, and Content-Type rejection; raw TCP WebSocket handshake helper correctly verifies the 403 response for cross-origin upgrade attempts.
packages/session-broker/src/daemon.test.ts Adds missing content-type header to existing broker API tests and new 415 test for plain-text bodies; coverage is thorough for the new guard.
CHANGELOG.md Changelog updated with security hardening entry and 0.13.0 / 0.12.1 version bumps; links are consistent.

Sequence Diagram

sequenceDiagram
    participant Client
    participant BunServe as Bun Serve Layer
    participant Handler as handleRequest (brokerServer)
    participant Daemon as SessionBrokerDaemon

    Client->>BunServe: HTTP / WebSocket request
    BunServe->>Handler: handleRequest(request)

    Handler->>Handler: validateHostHeader(request, port, allowRemote)
    alt Host missing or non-loopback
        Handler-->>Client: 400 / 403 JSON error
    end

    Handler->>Handler: validateOriginHeader(request, port, allowRemote)
    alt Origin present and non-local/wrong-port
        Handler-->>Client: 403 JSON error
    end

    alt "pathname == /health or /session-api/capabilities"
        Handler-->>Client: 200 JSON response
    else "pathname == /session-api POST"
        Handler->>Handler: hasJsonContentType?
        alt "Content-Type != application/json"
            Handler-->>Client: 415 JSON error
        else
            Handler->>Handler: parseJsonRequest + dispatch action
            Handler-->>Client: 200 JSON response
        end
    else "pathname == /session WebSocket upgrade"
        Handler-->>BunServe: undefined (defer to daemon)
        BunServe->>Daemon: upgrade + handleConnectionMessage
        Daemon-->>Client: WebSocket connection
    end
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/session-broker/src/daemon.ts:66-68
**Dead code — content-type guard inside `parseJsonRequest` is never reached**

`handleApiRequest` already returns a 415 response before calling `parseJsonRequest`, so the `hasJsonContentType` throw on line 67 is unreachable from its only call site. If a future caller invokes `parseJsonRequest` without the pre-check, the error would be caught by the outer `try/catch` and surfaced as a generic 400 (not 415), silently misrepresenting the failure.

### Issue 2 of 2
src/session-broker/brokerServer.ts:144-151
**Port validation is skipped when the Host header omits the port**

`isAllowedHostPort` returns `true` whenever `hostPort.port === undefined`, regardless of the daemon's actual listening port. A crafted HTTP client that sends `Host: localhost` or `Host: 127.0.0.1` (without a port number) will pass this check even when the daemon is running on a non-standard port like 47657. DNS-rebinding attacks from browsers always include the port in the Host header, so real-world risk is low, but the port guard provides no defence against non-browser clients that omit the port.

Reviews (1): Last reviewed commit: "fix(session): harden daemon HTTP request..." | Re-trigger Greptile

Comment thread packages/session-broker/src/daemon.ts Outdated
Comment on lines +66 to +68
if (!hasJsonContentType(request)) {
throw new Error("Expected Content-Type application/json.");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dead code — content-type guard inside parseJsonRequest is never reached

handleApiRequest already returns a 415 response before calling parseJsonRequest, so the hasJsonContentType throw on line 67 is unreachable from its only call site. If a future caller invokes parseJsonRequest without the pre-check, the error would be caught by the outer try/catch and surfaced as a generic 400 (not 415), silently misrepresenting the failure.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/session-broker/src/daemon.ts
Line: 66-68

Comment:
**Dead code — content-type guard inside `parseJsonRequest` is never reached**

`handleApiRequest` already returns a 415 response before calling `parseJsonRequest`, so the `hasJsonContentType` throw on line 67 is unreachable from its only call site. If a future caller invokes `parseJsonRequest` without the pre-check, the error would be caught by the outer `try/catch` and surfaced as a generic 400 (not 415), silently misrepresenting the failure.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the redundant content-type check from parseJsonRequest; the API handler remains responsible for returning the 415 before parsing.

Responded by Pi using OpenAI GPT-5.

Comment on lines +144 to +151
function isAllowedHostPort(
hostPort: { host: string; port?: number },
expectedPort: number,
options: { allowRemote: boolean },
) {
const hostAllowed = options.allowRemote || isLoopbackHost(hostPort.host);
return hostAllowed && (hostPort.port === undefined || hostPort.port === expectedPort);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Port validation is skipped when the Host header omits the port

isAllowedHostPort returns true whenever hostPort.port === undefined, regardless of the daemon's actual listening port. A crafted HTTP client that sends Host: localhost or Host: 127.0.0.1 (without a port number) will pass this check even when the daemon is running on a non-standard port like 47657. DNS-rebinding attacks from browsers always include the port in the Host header, so real-world risk is low, but the port guard provides no defence against non-browser clients that omit the port.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/session-broker/brokerServer.ts
Line: 144-151

Comment:
**Port validation is skipped when the Host header omits the port**

`isAllowedHostPort` returns `true` whenever `hostPort.port === undefined`, regardless of the daemon's actual listening port. A crafted HTTP client that sends `Host: localhost` or `Host: 127.0.0.1` (without a port number) will pass this check even when the daemon is running on a non-standard port like 47657. DNS-rebinding attacks from browsers always include the port in the Host header, so real-world risk is low, but the port guard provides no defence against non-browser clients that omit the port.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tightened host validation so an omitted Host port is treated as the HTTP default port and rejected unless it matches the daemon port. Added regression coverage for Host: 127.0.0.1 without a port.

Responded by Pi using OpenAI GPT-5.

@benvinegar benvinegar merged commit f89b57b into main May 19, 2026
5 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.

1 participant