Skip to content

Commit 445d536

Browse files
K. Kaushik Reddyclaude
authored andcommitted
fix(desktop): allow loopback origins so tunnel connections pass CORS
Connecting to a VM through the SSH tunnel failed with "Request failed with status code 403" as soon as the user tried to log in. The tunnel serves the web app from http://127.0.0.1:<sticky port>, not the VM's hostname, and a self-host deployment that sets CORS_ALLOWED_ORIGINS to its public hostname replaces the built-in list rather than extending it. Spring treats any request carrying Origin as cross-origin (the same-origin short-circuit went away in 5.3), so the backend answered 403 with the plain-text body "Invalid CORS request". That body has no `message` field, so client.js's axios interceptor fell through to axios's own wording — an error naming neither CORS nor the origin. It hid well: Chromium omits Origin on same-origin GETs, so the health probe, the SPA and every read succeeded and the connection tested green. Only the first POST — the login — failed. - probe.js sends an Origin header, so the rejection is caught at connect time rather than at the user's first login, and transport.js reports it as `cors-rejected` naming the origin and the exact allowlist to set. - Loopback entries in the shipped defaults now carry a port wildcard, since the tunnel's local port is chosen at runtime. Legal only because SecurityConfig uses setAllowedOriginPatterns. - .env.example, docker-compose.yml and desktop/README.md spell out that CORS_ALLOWED_ORIGINS replaces the list, which is how the loopback entries go missing. - CLAUDE.md and config.js drop the "zero backend changes" claim: CORS is the one setting the thin client does require. - tunnel-selftest.js grows two checks (14/14) covering the Origin header and a 403 from an allowlist that omits the origin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f4a43cb commit 445d536

10 files changed

Lines changed: 192 additions & 22 deletions

File tree

.env.example

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,17 @@ DEEPSQL_BACKEND_PORT=8080
218218
DEEPSQL_POSTGRES_PORT=5432
219219
DEEPSQL_VALKEY_PORT=6379
220220

221-
# Browser origins allowed to call the backend
222-
CORS_ALLOWED_ORIGINS=http://localhost:3000
221+
# Browser origins allowed to call the backend.
222+
#
223+
# This REPLACES the built-in list — it does not add to it. When you put your own
224+
# hostname here, keep the loopback patterns too. The desktop client reaches a VM
225+
# over an SSH tunnel and therefore serves the app from http://127.0.0.1:<port>,
226+
# with a port picked at runtime; if that origin is not allowed the app loads
227+
# normally and then every login fails with a bare "403 Invalid CORS request".
228+
# The `*` is a port wildcard (SecurityConfig uses setAllowedOriginPatterns).
229+
#
230+
# CORS_ALLOWED_ORIGINS=https://deepsql.example.com,http://127.0.0.1:*,http://localhost:*
231+
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:*,http://localhost:*
223232

224233
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
225234
# OPTIONAL — Email / SMTP

CLAUDE.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,28 @@ npm run selftest:tunnel # end-to-end SSH tunnel test (in-process SSH server)
138138
**It is a thin client and deliberately does not bundle the React frontend.** It
139139
navigates a `WebContentsView` at the real DeepSQL origin, so the UI is always the
140140
version the VM is running — no bundle/backend skew, and no second copy of 40+
141-
tabs to maintain. This works with **zero backend changes** because
142-
`docker/nginx/default.conf` already serves the SPA, `/api` and `/agent-api` from
143-
one origin: cookies, CORS and SSE behave exactly as in a browser. Do not
144-
"improve" this by bundling `dist/` — that reintroduces CORS, `SameSite`, and
141+
tabs to maintain. `docker/nginx/default.conf` already serves the SPA, `/api` and
142+
`/agent-api` from one origin, so cookies and SSE behave exactly as in a browser.
143+
Do not "improve" this by bundling `dist/` — that reintroduces `SameSite` and
145144
version-skew problems the current design does not have.
146145

146+
**It needs exactly one piece of backend configuration, and CORS is it.** The
147+
"zero backend changes" claim that used to sit here was wrong, and cost a long
148+
debugging session. Over a tunnel the origin is `http://127.0.0.1:<sticky port>`,
149+
not the VM's hostname, so a deployment whose `CORS_ALLOWED_ORIGINS` names only
150+
its public hostname rejects the desktop client. The failure is maximally
151+
misleading: Chromium omits `Origin` on same-origin GETs, so the health probe,
152+
the SPA and every read succeed, and the *first POST* — the login — comes back
153+
`403` with the plain-text body `Invalid CORS request`. That body has no
154+
`message` field, so `client.js`'s axios interceptor falls through to axios's own
155+
wording and the user sees **"Request failed with status code 403"**, which names
156+
neither CORS nor the origin. Fix: keep loopback patterns in the allowlist —
157+
`CORS_ALLOWED_ORIGINS=https://your-host,http://127.0.0.1:*,http://localhost:*`.
158+
Port wildcards work only because `SecurityConfig` uses
159+
`setAllowedOriginPatterns`; `setAllowedOrigins` would reject `*` alongside
160+
`allowCredentials(true)`. `probe.js` now sends an `Origin` header for exactly
161+
this reason, so the rejection is caught at connect time and named.
162+
147163
**Two transports, one abstraction.** Both resolve to an *origin*, so nothing
148164
downstream of `desktop/src/main/transport.js` knows which is in use:
149165

backend/src/main/resources/application-prod.properties

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,11 @@ app.base-url=${APP_BASE_URL:http://localhost:3000}
5757

5858
# CORS Configuration
5959
# Override CORS_ALLOWED_ORIGINS with the origin(s) your frontend is
60-
# actually served from. The default below covers local dev only.
61-
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001}
60+
# actually served from, and keep the loopback patterns: the desktop
61+
# client's SSH tunnel serves the app from http://127.0.0.1:<sticky port>,
62+
# so dropping them rejects every tunnel login with 403 "Invalid CORS
63+
# request". See the longer note in application.properties.
64+
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://127.0.0.1:*,http://localhost:*}
6265

6366
# File upload limits (slow query logs can be large)
6467
spring.servlet.multipart.max-file-size=2048MB

backend/src/main/resources/application.properties

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,22 @@ db-scheduler.shutdown-max-wait=45m
9393
db-scheduler.immediate-execution-enabled=true
9494

9595
# CORS Configuration
96-
# Localhost only by default so `mvn spring-boot:run` + `npm run dev` works out of the box.
96+
# Loopback only by default so `mvn spring-boot:run` + `npm run dev` works out of the box.
9797
# Any deployment serving a browser from another host must set CORS_ALLOWED_ORIGINS.
98-
# Note: exact origins (no patterns) for reliability.
99-
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://localhost:3002,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002}
98+
#
99+
# The loopback entries carry a port wildcard because the desktop client's SSH tunnel
100+
# serves the app from http://127.0.0.1:<sticky port>, and that port is chosen at runtime.
101+
# Enumerating ports here is what made every tunnel connection fail with a bare
102+
# "403 Invalid CORS request": Spring treats any request carrying Origin as cross-origin
103+
# (the same-origin short-circuit went away in 5.3), so an unlisted loopback port is
104+
# rejected. SecurityConfig uses setAllowedOriginPatterns, so `*` is legal in the port
105+
# position and stays compatible with allowCredentials(true) — plain setAllowedOrigins
106+
# would not be.
107+
#
108+
# IMPORTANT: overriding CORS_ALLOWED_ORIGINS replaces this list wholesale. A deployment
109+
# that sets it to its public hostname alone drops the loopback entries and breaks the
110+
# desktop client. Keep the loopback patterns alongside your hostname.
111+
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://localhost:3002,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:*,http://localhost:*}
100112

101113
# File upload limits (slow query logs can be large)
102114
spring.servlet.multipart.max-file-size=2048MB

desktop/README.md

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@ knowing:
2828

2929
- The UI is always the exact version the VM is running. There is no
3030
bundle/backend skew and no second copy of 40+ tabs to keep in step.
31-
- Cookies, CORS and SSE behave exactly as in a browser, because the app is
32-
served from one origin — `docker/nginx/default.conf` already fronts `/api`
33-
and `/agent-api` behind the frontend container. No backend change is needed
34-
to support the desktop client.
31+
- Cookies and SSE behave exactly as in a browser, because the app is served
32+
from one origin — `docker/nginx/default.conf` already fronts `/api` and
33+
`/agent-api` behind the frontend container.
34+
- **CORS is the one exception, and the one backend setting you must get right.**
35+
Over a tunnel that origin is `http://127.0.0.1:<port>`, so the VM's
36+
`CORS_ALLOWED_ORIGINS` has to allow it. See
37+
[CORS on the VM](#cors-on-the-vm-the-403-nobody-can-read).
3538
- Everything the client adds is what a browser tab cannot show: which VM you
3639
are on, how you are reaching it, and whether that path is healthy.
3740

@@ -129,6 +132,52 @@ anything publicly.
129132
`http://127.0.0.1:<port>` is a secure context in Chromium, so the backend's
130133
`Secure` session cookies are still accepted over the tunnel.
131134

135+
### CORS on the VM (the 403 nobody can read)
136+
137+
The tunnel gives the web app the origin `http://127.0.0.1:<port>`, not your VM's
138+
hostname. If `CORS_ALLOWED_ORIGINS` on the VM names only the hostname, the
139+
backend rejects that origin — and it does so in the most confusing way
140+
available:
141+
142+
- Chromium omits `Origin` on same-origin **GET**s, so the health probe, the SPA
143+
and every read work. The connection tests green.
144+
- Chromium *does* send `Origin` on same-origin **POST/PUT/DELETE**, and Spring
145+
treats any request carrying `Origin` as cross-origin (the same-origin
146+
short-circuit was removed in Spring 5.3). So the first POST — the login —
147+
returns `403` with the plain-text body `Invalid CORS request`.
148+
- That body has no `message` field, so the web app's axios interceptor falls
149+
back to axios's own wording: **"Request failed with status code 403"**, naming
150+
neither CORS nor the origin.
151+
152+
Fix it on the VM by keeping loopback patterns in the allowlist alongside your
153+
hostname, then restarting the backend:
154+
155+
```bash
156+
# /home/<user>/deepsql-self-host/.env
157+
CORS_ALLOWED_ORIGINS=https://deepsql.example.com,http://127.0.0.1:*,http://localhost:*
158+
159+
docker compose up -d backend
160+
```
161+
162+
The `*` is a **port** wildcard, which matters because the tunnel's local port is
163+
chosen at runtime; enumerating ports means re-editing the VM whenever it
164+
changes. It is legal only because `SecurityConfig` uses
165+
`setAllowedOriginPatterns` — plain `setAllowedOrigins` rejects `*` in
166+
combination with `allowCredentials(true)`. Note that `CORS_ALLOWED_ORIGINS`
167+
*replaces* the built-in list rather than extending it, which is how the loopback
168+
entries usually go missing.
169+
170+
Verify without opening the app:
171+
172+
```bash
173+
curl -s -o /dev/null -w '%{http_code}\n' \
174+
-H "Origin: http://127.0.0.1:<port>" http://127.0.0.1:<port>/api/actuator/health
175+
# 200 = allowed · 403 = still missing from CORS_ALLOWED_ORIGINS
176+
```
177+
178+
Since the probe now sends an `Origin` header, the launcher catches this at
179+
connect time and says so, instead of letting it surface as a failed login.
180+
132181
## Security model
133182

134183
- **Secrets** (key passphrases, SSH passwords) are encrypted with Electron

desktop/scripts/tunnel-selftest.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,23 @@ app.whenReady().then(async () => {
4040
fs.writeFileSync(keyPath, privateKey, { mode: 0o600 });
4141

4242
// Stands in for the DeepSQL nginx inside the VM.
43+
//
44+
// It also emulates Spring's CORS check, because that is a real failure mode
45+
// of the tunnel transport: the origin is http://127.0.0.1:<sticky port>, and
46+
// a VM whose CORS_ALLOWED_ORIGINS lists only its hostname answers 403 with a
47+
// plain-text body. Spring rejects on the presence of `Origin` alone — there
48+
// is no same-origin exemption — so this mirrors that rule exactly.
49+
let lastProbeOrigin = null;
50+
let corsAllowedOrigins = null; // null = allow everything
4351
const upstream = http.createServer((req, res) => {
4452
if (req.url === '/api/actuator/health') {
53+
const origin = req.headers.origin || null;
54+
lastProbeOrigin = origin;
55+
if (origin && corsAllowedOrigins && !corsAllowedOrigins.includes(origin)) {
56+
res.writeHead(403, { 'Content-Type': 'text/plain' });
57+
res.end('Invalid CORS request');
58+
return;
59+
}
4560
res.writeHead(200, { 'Content-Type': 'application/json' });
4661
res.end('{"status":"UP"}');
4762
return;
@@ -107,6 +122,24 @@ app.whenReady().then(async () => {
107122
forwardedTo,
108123
);
109124

125+
// The probe must announce the origin the browser is about to use, or a CORS
126+
// allowlist that omits it stays invisible until the user's first login POST.
127+
check(
128+
'probe sends the browser origin so CORS is checked at connect time',
129+
lastProbeOrigin === `http://127.0.0.1:${localPort}`,
130+
lastProbeOrigin === null ? 'no Origin header sent' : lastProbeOrigin,
131+
);
132+
133+
// Same tunnel, same healthy backend — only the allowlist changes.
134+
corsAllowedOrigins = ['https://deepsql.example.com'];
135+
const corsHealth = await probe(`http://127.0.0.1:${localPort}`, profile);
136+
check(
137+
'an origin missing from CORS_ALLOWED_ORIGINS fails the probe',
138+
!corsHealth.ok && corsHealth.status === 403,
139+
`ok=${corsHealth.ok} status=${corsHealth.status}`,
140+
);
141+
corsAllowedOrigins = null;
142+
110143
// The listener must never be reachable from anything but loopback.
111144
check(
112145
'listener is bound to loopback only',

desktop/src/main/config.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@
77
* It navigates a WebContentsView at the real DeepSQL origin (reached either
88
* directly over TLS or through an SSH tunnel), so the UI a user sees is always
99
* exactly the version their VM is running. There is no bundle/backend skew to
10-
* manage, and cookies/CORS behave the same as they do in a browser because the
11-
* app content is served from a single origin (the frontend nginx in
10+
* manage, and cookies behave the same as they do in a browser because the app
11+
* content is served from a single origin (the frontend nginx in
1212
* docker/nginx/default.conf already fronts /api and /agent-api).
13+
*
14+
* CORS is the one thing that does *not* come for free: over a tunnel that single
15+
* origin is http://127.0.0.1:<sticky port>, which the VM's CORS_ALLOWED_ORIGINS
16+
* has to allow. See the note in probe.js — the probe sends an Origin header so a
17+
* missing entry is caught here rather than at the user's first login attempt.
1318
*/
1419

1520
const path = require('node:path');

desktop/src/main/probe.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@
77
* proxies (docker/nginx/default.conf routes /api/ to the backend), so a 200
88
* here proves the whole chain — transport, nginx, backend — not just that a
99
* TCP port answered.
10+
*
11+
* The probe deliberately sends an `Origin` header naming the origin the
12+
* embedded browser is about to load. Spring treats *any* request carrying
13+
* `Origin` as a CORS request (the same-origin short-circuit was dropped in
14+
* Spring 5.3), so an origin missing from the backend's `CORS_ALLOWED_ORIGINS`
15+
* is rejected with `403 Invalid CORS request` — and this probe sees it.
16+
*
17+
* Without the header the probe passes and the failure surfaces much later and
18+
* much further away: Chromium omits `Origin` on same-origin GETs, so the SPA
19+
* loads fine and only the first POST — the login itself — fails, as an opaque
20+
* "Request failed with status code 403" (the body is plain text, so the web
21+
* app's axios interceptor finds no `.message` to show). Over a tunnel this is
22+
* near-guaranteed, because the origin is `http://127.0.0.1:<sticky port>` and
23+
* no deployment lists that by hand. Same reasoning as verifyForwarding():
24+
* check the thing up front, where it can still be explained.
1025
*/
1126

1227
const http = require('node:http');
@@ -47,7 +62,14 @@ function probe(origin, profile, { timeoutMs = DEFAULT_TIMEOUT_MS, path = HEALTH_
4762
try {
4863
options = {
4964
method: 'GET',
50-
headers: { Accept: 'application/json', 'User-Agent': 'DeepSQL-Desktop' },
65+
headers: {
66+
Accept: 'application/json',
67+
'User-Agent': 'DeepSQL-Desktop',
68+
// `url.origin` rather than the caller's string: it is normalised
69+
// (default ports dropped, no trailing slash) exactly as a browser
70+
// would send it, so the check matches what Chromium does next.
71+
Origin: url.origin,
72+
},
5173
...(isHttps ? tls.nodeTlsOptions(profile) : {}),
5274
};
5375
} catch (err) {

desktop/src/main/transport.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ class Transport extends EventEmitter {
9696
const health = await probe(origin, profile);
9797
if (!health.ok) {
9898
if (tunnel) await tunnel.stop();
99-
throw new TunnelError('unreachable', explainProbeFailure(profile, health));
99+
throw new TunnelError(
100+
health.status === 403 ? 'cors-rejected' : 'unreachable',
101+
explainProbeFailure(profile, origin, health),
102+
);
100103
}
101104

102105
// First successful TLS handshake in pinned/insecure mode establishes the pin.
@@ -192,7 +195,21 @@ class Transport extends EventEmitter {
192195
* `Host: 127.0.0.1:<port>`. Say so, because "HTTP 404" on its own sends people
193196
* looking at the backend.
194197
*/
195-
function explainProbeFailure(profile, health) {
198+
function explainProbeFailure(profile, origin, health) {
199+
// 403 is its own diagnosis and applies to both transports: the probe sends an
200+
// Origin header, so the one thing that rejects a *reachable, healthy* DeepSQL
201+
// with 403 is its CORS allowlist. Checked before the tunnel branch below,
202+
// which would otherwise blame the remote port for a backend that answered
203+
// perfectly well.
204+
if (health.status === 403) {
205+
return (
206+
`DeepSQL is running and reachable, but its backend refused the origin ${origin} ` +
207+
'(HTTP 403, "Invalid CORS request"). Add that origin to CORS_ALLOWED_ORIGINS on the ' +
208+
'VM and restart the backend. A port wildcard is the durable form, because the local ' +
209+
'port changes: `CORS_ALLOWED_ORIGINS=https://your-host,http://127.0.0.1:*,' +
210+
'http://localhost:*`. Left unfixed, DeepSQL would load but every login would fail.'
211+
);
212+
}
196213
if (profile.transport !== 'tunnel' || !health.status) return health.detail;
197214
const { remoteHost, remotePort } = profile.ssh;
198215
return (

docker-compose.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,12 @@ services:
9494
ENCRYPTION_KEYS: ${ENCRYPTION_KEYS:-}
9595
ENCRYPTION_KEY_ID: ${ENCRYPTION_KEY_ID:-}
9696

97-
# CORS — allow the frontend service (and optional custom domain)
98-
cors.allowed.origins: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
97+
# CORS — allow the frontend service (and optional custom domain).
98+
# Keep the loopback patterns when you set CORS_ALLOWED_ORIGINS: this
99+
# variable REPLACES the list, and the desktop client's SSH tunnel serves
100+
# the app from http://127.0.0.1:<sticky port>. Dropping them makes every
101+
# tunnel login fail with a bare 403 "Invalid CORS request".
102+
cors.allowed.origins: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:*,http://localhost:*}
99103

100104
# DeepSQL Agent — compose-network URLs (Agent tab, dashboards, Slack/CLI)
101105
AGENT_WEBUI_URL: ${AGENT_WEBUI_URL:-http://deepsql-agent:8787}

0 commit comments

Comments
 (0)