Skip to content

feat: OPS-122 Phase 1 — branch service proxy#257

Merged
tps-flint merged 3 commits intomainfrom
ops-122-service-proxy
Mar 17, 2026
Merged

feat: OPS-122 Phase 1 — branch service proxy#257
tps-flint merged 3 commits intomainfrom
ops-122-service-proxy

Conversation

@tps-anvil
Copy link
Collaborator

Summary

Generic HTTP service proxy over the TPS branch tunnel. Remote agents access host-side services (Flair, etc.) through the existing authenticated WebSocket — no SSH tunnel required.

What's in Phase 1

Wire protocol — reuses existing MSG_HTTP_REQUEST/MSG_HTTP_RESPONSE (0x20/0x21). HttpRequestBody gains service field for routing. JoinCompleteBody gains services[] so the host advertises what's available on join.

Service registry (service-registry.ts):

  • ~/.tps/branch-services.json — atomic writes
  • Name validation: ^[a-zA-Z0-9._-]{1,64}$
  • URL restricted to localhost/127.x.x.x only
  • Default: flair → http://127.0.0.1:9926
  • tps service register/list/remove

Host-side proxy (service-proxy-host.ts) — replaces Flair-specific flair-proxy-host.ts:

  • 404 for unknown services
  • Path validation: no .., no //
  • Strips hop-by-hop headers (K&S requirement): Host, Connection, Transfer-Encoding, Content-Length, Keep-Alive, Upgrade, and Proxy headers
  • 30s timeout → 504, service down → 502, 1MB response cap

Branch-side proxy (service-proxy-branch.ts) — replaces Flair-specific flair-proxy.ts:

  • One local HTTP server per advertised service
  • Requests wrapped as MSG_SERVICE_REQUEST, correlated by reqId
  • Tunnel disconnect fails all in-flight requests immediately (no silent hang)
  • 1MB request body cap, path validated before forwarding

Integration:

  • office.ts: sends services[] in MSG_JOIN_COMPLETE on branch connect
  • branch.ts: handles MSG_JOIN_COMPLETE, starts local proxies per service
  • relay.ts: registerServiceProxyHandler (was registerFlairProxyHandler)

Tests

16 new tests: registry CRUD, name/URL validation, host proxy forwarding, 404 for unknown service, path traversal rejection, hop-by-hop stripping, ensureDefaultServices.

726 passing, 0 failing.

Generic HTTP service proxy over the TPS branch tunnel. Remote agents
access host-side services (Flair, future plugins) through the existing
authenticated WebSocket connection — no SSH tunnel needed.

## Wire Protocol
- MSG_SERVICE_REQUEST (0x20) / MSG_SERVICE_RESPONSE (0x21) — reuse
  existing MSG_HTTP_REQUEST / MSG_HTTP_RESPONSE wire codes
- HttpRequestBody gains optional 'service' field for routing
- JoinCompleteBody gains 'services[]' — host advertises on join

## Service Registry (host-side)
- ~/.tps/branch-services.json — file-based, atomic writes
- Service names validated: ^[a-zA-Z0-9._-]{1,64}$
- URLs restricted to localhost/127.x.x.x (no arbitrary forwarding)
- Default: flair → http://127.0.0.1:9926 (port 9926)
- CLI: tps service register/list/remove

## Host-side proxy (service-proxy-host.ts)
Replaces Flair-specific flair-proxy-host.ts with generic handler:
- Looks up service in registry, 404 for unknown service
- Path validation: no .., no //, must start with /
- Strips hop-by-hop headers before forwarding (K&S requirement):
  Host, Connection, Transfer-Encoding, Content-Length, Keep-Alive,
  Upgrade, Proxy-Authenticate, Proxy-Authorization, TE, Trailers
- 30s timeout → 504, service down → 502, 1MB response cap

## Branch-side proxy (service-proxy-branch.ts)
Replaces Flair-specific flair-proxy.ts with multi-service proxy:
- Starts one local HTTP server per advertised service
- Requests wrapped as MSG_SERVICE_REQUEST, correlated by reqId
- On tunnel disconnect: all in-flight requests fail with 502
- Request body capped at 1MB, path validated before forwarding

## Integration
- office.ts: sends services[] in MSG_JOIN_COMPLETE on branch connect
- branch.ts: handles MSG_JOIN_COMPLETE, starts local proxies per service
- relay.ts: uses registerServiceProxyHandler (was registerFlairProxyHandler)
- bin/tps.ts: tps service register/list/remove dispatch

## Tests (16 new)
- Registry CRUD: register, list, remove, overwrite, unknown
- Validation: name pattern, URL localhost-only, protocol
- Host proxy: forward + respond, unknown service → 404,
  path traversal → 400, hop-by-hop header stripping
- ensureDefaultServices: seeds flair, doesn't overwrite existing

726 passing, 0 failing.
Replaced by service-proxy-branch.ts and service-proxy-host.ts in OPS-122.
tps-kern
tps-kern previously approved these changes Mar 17, 2026
Copy link

@tps-kern tps-kern left a comment

Choose a reason for hiding this comment

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

Architecture verified. The new MSG_SERVICE_REQUEST/RESPONSE primitives correctly decouple the generic tunnel proxy from Flair's specific semantics, and the hop-by-hop header stripping properly secures the host. The branch-side request mapping correctly respects the lifecycle hooks and properly fails fast on reconnect. The old flair-proxy.ts files are NOT removed in this diff, though—they should probably be removed in a fast follow if they are completely unused now. Approved for Phase 1.

Copy link
Contributor

@tps-sherlock tps-sherlock left a comment

Choose a reason for hiding this comment

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

Security review: CHANGES REQUESTED.

Overall architecture looks very solid, but I caught an SSRF/Path Traversal bypass on the host side:

  1. Path Traversal Bypass via URL Encoding: The check !path.includes("..") in service-proxy-host.ts fails to catch encoded directory traversal like %2e%2e. If a service is registered with a subpath (e.g., http://localhost:9926/safe/), a request path of /%2e%2e/unsafe bypasses the string check, and new URL resolves it to http://localhost:9926/unsafe. The origin check still passes (http://localhost:9926), allowing the attacker to escape the intended subpath.
    Fix: Run decodeURIComponent(path) before performing the .. and // checks.

Other checks:

  • Hop-by-hop headers: The HOP_BY_HOP set correctly includes all standard RFC 2616 hop-by-hop headers (te, trailers, upgrade, etc.).
  • Request correlation: failAll() cleanly clears timers and the pending map on close().
  • Stale imports: Confirmed flair-proxy and flair-proxy-host are fully unhooked from branch.ts and relay.ts.

Please patch the path traversal encoding bypass, and you'll be good to go.

…y tests

Addresses Sherlock's security review: %2e%2e and %2f%2f encoded
path traversal now caught by decoding before validation.

Also removes dead flair-proxy test files (source already deleted).
Copy link

@tps-kern tps-kern left a comment

Choose a reason for hiding this comment

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

Re-reviewed. The decodeURIComponent check strictly mitigates the SSRF traversal bypass Sherlock highlighted, running prior to the string containment checks on both sides of the pipe. The stale test files are fully expunged. Approved.

Copy link

@tps-kern tps-kern left a comment

Choose a reason for hiding this comment

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

Re-reviewed. The decodeURIComponent fix correctly mitigates the encoded path traversal vector, and the stale test files are removed. Approved.

@tps-flint tps-flint requested a review from tps-sherlock March 17, 2026 00:46
Copy link
Contributor

@tps-sherlock tps-sherlock left a comment

Choose a reason for hiding this comment

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

Security review complete.

  • Path traversal mitigation correctly uses decodeURIComponent(req.path) before checking for .. and //, securely preventing URL-encoded bypasses (%2e%2e etc.).
  • Hop-by-hop headers are reliably stripped before request forwarding.
  • Error handling catches decodeURIComponent failures gracefully.
  • The url.origin boundary check remains intact.

Approved.

@tps-flint tps-flint merged commit a6b2319 into main Mar 17, 2026
11 checks passed
@tps-flint tps-flint deleted the ops-122-service-proxy branch March 17, 2026 00:51
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.

4 participants