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.
Subdomain routing (recommended)¶
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.comand*.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):
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
Locationheaders 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¶
- Browser sends
GET https://site-foo.hub.example.com:8080/qie/... - Main Jetty receives the request;
HubProxyFilterresolves the site by Host header. - Dispatcher allocates a fresh
reqIdand submits aREQUEST_HEADframe to the site's tunnel multiplexer, followed by any number ofREQUEST_BODYframes. - Remote QIE's tunnel client receives the frames, reassembles the
HTTP request, and replays it against its own
localhost:<jettyPort>main Jetty. - Remote QIE's main Jetty handles the request through its normal Spring filter chain and writes a response.
- Tunnel client reads the response, emits a
RESPONSE_HEADframe and a stream ofRESPONSE_BODYframes back to the Hub. - 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.
Cookie scoping¶
Set-Cookie headers are forwarded with two adjustments:
- The cookie's
Pathattribute is rewritten to/if absent. - The cookie's
Domainattribute is dropped. A remote QIE cannot set cookies that span across other subdomains of the Hub. - The
Secureflag 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_labellookup is cached so a busy console does not re-queryhub_siteon 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 |