Skip to content

HTTP Proxy

The HTTP proxy is the Hub's user-facing entry point for managing remote QIE sites. The operator clicks a site in the Sites Dashboard and a new browser tab opens against the site's subdomain. From that point on the browser sees the remote QIE's admin UI exactly as if it were connected directly to the site.

Under the hood, every request to the per-site subdomain is resolved to a hub_site row, dispatched over that site's open mTLS tunnel as a multiplexed stream, and replayed against the remote QIE's localhost Jetty. The response streams back the same way.

The production routing model is subdomain-per-site. The browser receives a Host header in the form site-<label>.hub.example.com; the Hub resolves <label> to a hub_site by subdomain_label and proxies through that site's tunnel.

URL the operator clicks Resolves to
https://site-foo.hub.example.com:8080/qie/ hub_site row with subdomain_label = 'foo'
https://site-east-coast-radiology.hub.example.com:8080/qie/ hub_site row with subdomain_label = 'east-coast-radiology'

Every site gets its own browser origin. This is the only safe production configuration because browser same-origin policy is the last line of defense against a compromised site's JavaScript trying to read data from another site the same operator has open in another tab.

Prerequisites for subdomain routing:

  • Wildcard DNS under your Hub's domain (e.g. *.hub.example.com).
  • A wildcard TLS certificate that covers both hub.example.com and *.hub.example.com, presented by the main Jetty.

Both are documented in the Install Guide.

Path-based routing (alternative to subdomain)

Subdomain routing needs per-site wildcard DNS and a wildcard TLS cert. Some deployments cannot or would rather not provision those. For them the proxy also supports path-based routing, enabled by an explicit JVM option (also settable as an environment variable for container deployments):

-Dqie.hubProxyAllowPathBased=true

In this mode the proxy also accepts https://hub-host:8080/sites/<label>/... URLs. The <label> is resolved exactly as the subdomain label would be; the rest of the path is the actual request the remote QIE sees. The Hub then needs only one hostname and one TLS cert, with no wildcard DNS and no wildcard cert.

Subdomain routing is strongly recommended: know the trade-off before choosing path-based

Path-based mode puts every site under the Hub's single browser origin. That:

  • removes cookie-jar isolation between sites. Every site reads and writes cookies under the Hub host name, so a malicious QIE site can reach another site's session cookie;
  • removes the same-origin firewall that otherwise stops a compromised remote QIE's JavaScript from reading another site's DOM in an adjacent tab;
  • forces the proxy to rewrite Location headers into the /sites/<label>/... prefix, with the corresponding fragility.

Subdomain routing gives each site its own browser origin and avoids all of the above, which is why it is the recommended model. Path-based is fully supported for operators who accept this trade-off in exchange for not managing per-site wildcard DNS and a wildcard certificate. The decision, and its risk, is yours.

When the flag is not set (the default), /sites/<label>/... URIs fall through to the regular Spring filter chain and behave like any other unknown path. So a deployment that simply does not set the flag is inherently safe even if an operator forgets the flag's existence.

Click-through UX

Clicking Open on a site in the Sites Dashboard (or double-clicking a row, then Open) opens a new browser tab against the site's subdomain. The dashboard tab stays live, so the operator can keep an eye on overall health while working in individual sites.

Each opened tab:

  • Has its own QIE session cookie scoped to that subdomain.
  • Renders the remote QIE's existing admin UI (login screen first, then the normal home page after authentication).
  • Is closed independently of the dashboard tab; closing the dashboard does not close site tabs.

There is no single sign-on. The operator logs into each remote QIE separately. The remote QIE's authentication mechanism (local users, LDAP, OIDC) is unchanged by the proxy. The proxy only transports the bytes.

Per-request flow

Proxy per-request flow: browser to the Hub main Jetty (which resolves the subdomain to a site), then multiplexed frames over the mTLS tunnel to the remote QIE, which replays the request on its localhost Jetty; the response streams back and is sanitized and cookie-scoped before returning to the browser.

  1. Browser sends GET https://site-foo.hub.example.com:8080/qie/...
  2. Main Jetty receives the request; HubProxyFilter resolves the site by Host header.
  3. Dispatcher allocates a fresh reqId and submits a REQUEST_HEAD frame to the site's tunnel multiplexer, followed by any number of REQUEST_BODY frames.
  4. Remote QIE's tunnel client receives the frames, reassembles the HTTP request, and replays it against its own localhost:<jettyPort> main Jetty.
  5. Remote QIE's main Jetty handles the request through its normal Spring filter chain and writes a response.
  6. Tunnel client reads the response, emits a RESPONSE_HEAD frame and a stream of RESPONSE_BODY frames back to the Hub.
  7. Dispatcher applies header sanitization and cookie scoping, then writes the response stream back to the browser.

Each step is independent and streaming, so large request bodies (file uploads of message archives, for example) and large response bodies (log tailing, configuration export) do not block other in-flight requests. Per-stream flow control does the work: HTTP/2-style credit windows, 64KB initial per stream, 32 in-flight requests per tunnel.

Header sanitization

A compromised remote QIE could in principle return arbitrary response headers. The proxy sanitizes the response stream before writing it back to the browser.

Stripped response headers

These are dropped unconditionally. A remote QIE cannot influence browser security headers for the Hub origin:

Header Reason
Strict-Transport-Security Could pin the operator's browser to an attacker-chosen TLS configuration
Content-Security-Policy Could relax CSP for the per-site subdomain in attacker-chosen ways
Content-Security-Policy-Report-Only Same
X-Frame-Options Could change clickjacking protection
Public-Key-Pins Could pin the browser to an attacker's keys
Public-Key-Pins-Report-Only Same

Forwardable response headers (allowlist)

Only these headers are forwarded from a remote QIE response to the browser:

content-type, content-encoding, content-disposition,
content-language, cache-control, expires, etag, last-modified, vary,
pragma, set-cookie, location, x-gwt-permutation,
x-content-type-options, x-requested-with, accept-ranges,
content-range, allow, retry-after

Anything not in this list is silently dropped. (Content-Length is deliberately not forwarded from the remote response. The servlet container sets/chunks it from the bytes actually streamed, so a mismatched declared length from a compromised site cannot truncate or mis-frame the response.) New headers required by new QIE features must be added to the allowlist explicitly.

Set-Cookie headers are forwarded with two adjustments:

  • The cookie's Path attribute is rewritten to / if absent.
  • The cookie's Domain attribute is dropped. A remote QIE cannot set cookies that span across other subdomains of the Hub.
  • The Secure flag is added when the request was over TLS.

This keeps each site's session cookies scoped to its own subdomain even when the remote QIE issues a cookie with a domain-wide scope.

GWT-RPC handling

GWT-RPC payloads carry the full URL of the GWT module's bootstrap location in their moduleBaseURL field. In subdomain mode this is already correct. Both Hub-side and QIE-side see the same /qie/ prefix. In path-based fallback mode, however, the browser bootstraps from /sites/<label>/qie/, the RPC payload reports /sites/<label>/qie/ as moduleBaseURL, and the QIE's RemoteServiceServlet would derive a .gwt.rpc policy-lookup path that does not exist on the QIE's webapp.

To make path-based mode work, the proxy injects an X-Hub-Proxy-Strip-Path: /sites/<label> header into every proxied request. The QIE-side SpringHB4GWTRPCServiceExporter registers a custom ModulePathTranslation that reads this header and strips the prefix from moduleBaseURL before the policy lookup runs. This is invisible to the operator; subdomain mode does not need it.

Login brute-force throttle

POSTs to any site's /login path through the proxy are rate-limited per source IP to defend against brute-force password guessing.

The defaults are conservative:

Setting Default System property
Attempts per window 10 qie.hubLoginThrottleThreshold
Window length 5 minutes qie.hubLoginThrottleWindowMs
Cooldown after threshold 15 minutes qie.hubLoginThrottleCooldownMs

When an IP exceeds the threshold within the window, it enters cooldown and every /login POST from that IP is rejected with HTTP 429 until the cooldown elapses. The first rejected request emits a SITE_LOGIN_ATTEMPT_THROTTLED audit event with the source IP, site identifier, and subdomain label.

The throttle is in-memory per Hub instance. In an HA deployment, sticky LB affinity (required for proxy sessions anyway) means a given attacker IP usually hits the same instance, which is sufficient for "slow down brute force", not for "provide a global counter".

The throttle is intentionally conservative because every false positive locks out a legitimate operator for 15 minutes. In deployments where multiple operators share a NAT'd egress IP, raise threshold to a value that accommodates the working population.

Client IP resolution behind the load balancer

The Hub always runs behind a load balancer, so the direct socket peer is the LB, not the operator's machine. The real client IP, used for the login throttle above, for audit-trail attribution, and for the X-Forwarded-For the Hub forwards to the remote QIE, is read from X-Forwarded-For. Because that header is client-supplied and appended left-to-right, the Hub reads the Nth entry from the right, where N is the number of trusted proxies in front of it:

Setting Default System property
Trusted proxy hops 1 qie.hubTrustedProxyHops

The default of 1 matches a single load balancer: the LB stamps the true connecting address as the last (rightmost) X-Forwarded-For entry, so a client that pre-seeds the header cannot forge the resolved IP. Increase it only if the Hub sits behind additional trusted proxies (for example a CDN in front of the LB, then set it to 2). The Hub never forwards the browser's raw X-Forwarded-For into the tunnel: it strips it and stamps the value it resolved, so the remote QIE can never be fed a spoofed client IP.

Hot-path caching

Every proxied request (every click, every GWT-RPC call, every asset fetch of an open console) resolves the target site and checks the per-site PROXY permission. At scale (up to ~1000 connected sites, each with an active console) running those lookups against the database on every request is the practical ceiling on concurrent admin usage. Two hot-path caches remove the repeat DB work:

  • Subdomain → site resolution. The subdomain_label lookup is cached so a busy console does not re-query hub_site on every request. Only successful resolutions are cached, so a newly added site resolves immediately.
  • Proxy permission. The (user, site) PROXY verdict is cached so the 2–3 ACL lookups it otherwise costs run at most once per TTL.

Both caches are bounded by a time-to-live so any missed invalidation self-heals within that window:

Setting Default System property
Cache TTL 60 seconds qie.hubProxyCacheTtlMs

The TTL is the safety net, not the only invalidation. Disabling or deleting a site evicts its subdomain-resolution entry immediately, and granting or revoking a PROXY permission evicts (or, for role-level changes, clears) the permission cache immediately, so those administrative changes take effect on the next request. The TTL only bounds the exposure of a change that was not explicitly evicted (for example, a change to a user's global administrator authority), matching the revoked-certificate cache's own 60-second refresh window. Lowering the TTL tightens that bound at the cost of more DB load; setting it to 0 disables the caches entirely.

Cancellation

When the browser disconnects mid-request (operator closed the tab, hit Stop, browser crashed), the Hub sends a CANCEL(reqId) frame to the remote QIE. The remote QIE's tunnel client aborts the local Jetty request and stops sending body frames. Symmetrically, if the remote QIE drops the request (process exit, tunnel close), the Hub returns a 502 to the browser.

This prevents resource leaks on either side from abandoned requests and is essential for streaming endpoints like log tailing.

Streaming and bandwidth

Per-stream credit windows (64KB initial, replenished as the receiver consumes) ensure that one slow operator on a large upload does not block other requests sharing the same tunnel. The tunnel itself is WebSocket-permessage-deflate compressed.

Per-tunnel bandwidth limits are configurable globally (not yet exposed in the UI). For most deployments the bottleneck is the hospital's egress link, not the Hub's ingress.

When the proxy fails

Symptom Likely cause
502 Bad Gateway Tunnel went down between operator click and request; check Sites Dashboard, the site is probably red
503 Service Unavailable Tunnel is at 32 in-flight requests (very rare at normal admin volume); retry
404 from the Hub (not from the QIE) Subdomain label not found in hub_site; either typo'd hostname or site was deleted while a tab was open
429 Too Many Requests on /login Login throttle engaged; see above
Cookie warnings in the browser Almost always a misconfiguration of the wildcard TLS cert or a missing Secure attribute on the operator's wildcard cert. See Troubleshooting