⚡ Sangsakala Proxy 🏠 Home
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
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>
  • Loading branch information
2 people authored and K. Kaushik Reddy committed Aug 16, 2026
commit 445d536ec7f51a6b8af0f883b09c7f616867903e
13 changes: 11 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,17 @@ DEEPSQL_BACKEND_PORT=8080
DEEPSQL_POSTGRES_PORT=5432
DEEPSQL_VALKEY_PORT=6379

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

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

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

**Two transports, one abstraction.** Both resolve to an *origin*, so nothing
downstream of `desktop/src/main/transport.js` knows which is in use:

Expand Down
7 changes: 5 additions & 2 deletions backend/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ app.base-url=${APP_BASE_URL:http://localhost:3000}

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

# File upload limits (slow query logs can be large)
spring.servlet.multipart.max-file-size=2048MB
Expand Down
18 changes: 15 additions & 3 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,22 @@ db-scheduler.shutdown-max-wait=45m
db-scheduler.immediate-execution-enabled=true

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

# File upload limits (slow query logs can be large)
spring.servlet.multipart.max-file-size=2048MB
Expand Down
57 changes: 53 additions & 4 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ knowing:

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

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

### CORS on the VM (the 403 nobody can read)

The tunnel gives the web app the origin `http://127.0.0.1:<port>`, not your VM's
hostname. If `CORS_ALLOWED_ORIGINS` on the VM names only the hostname, the
backend rejects that origin — and it does so in the most confusing way
available:

- Chromium omits `Origin` on same-origin **GET**s, so the health probe, the SPA
and every read work. The connection tests green.
- Chromium *does* send `Origin` on same-origin **POST/PUT/DELETE**, and Spring
treats any request carrying `Origin` as cross-origin (the same-origin
short-circuit was removed in Spring 5.3). So the first POST — the login —
returns `403` with the plain-text body `Invalid CORS request`.
- That body has no `message` field, so the web app's axios interceptor falls
back to axios's own wording: **"Request failed with status code 403"**, naming
neither CORS nor the origin.

Fix it on the VM by keeping loopback patterns in the allowlist alongside your
hostname, then restarting the backend:

```bash
# /home/<user>/deepsql-self-host/.env
CORS_ALLOWED_ORIGINS=https://deepsql.example.com,http://127.0.0.1:*,http://localhost:*

docker compose up -d backend
```

The `*` is a **port** wildcard, which matters because the tunnel's local port is
chosen at runtime; enumerating ports means re-editing the VM whenever it
changes. It is legal only because `SecurityConfig` uses
`setAllowedOriginPatterns` — plain `setAllowedOrigins` rejects `*` in
combination with `allowCredentials(true)`. Note that `CORS_ALLOWED_ORIGINS`
*replaces* the built-in list rather than extending it, which is how the loopback
entries usually go missing.

Verify without opening the app:

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Origin: http://127.0.0.1:<port>" http://127.0.0.1:<port>/api/actuator/health
# 200 = allowed · 403 = still missing from CORS_ALLOWED_ORIGINS
```

Since the probe now sends an `Origin` header, the launcher catches this at
connect time and says so, instead of letting it surface as a failed login.

## Security model

- **Secrets** (key passphrases, SSH passwords) are encrypted with Electron
Expand Down
33 changes: 33 additions & 0 deletions desktop/scripts/tunnel-selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,23 @@ app.whenReady().then(async () => {
fs.writeFileSync(keyPath, privateKey, { mode: 0o600 });

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

// The probe must announce the origin the browser is about to use, or a CORS
// allowlist that omits it stays invisible until the user's first login POST.
check(
'probe sends the browser origin so CORS is checked at connect time',
lastProbeOrigin === `http://127.0.0.1:${localPort}`,
lastProbeOrigin === null ? 'no Origin header sent' : lastProbeOrigin,
);

// Same tunnel, same healthy backend — only the allowlist changes.
corsAllowedOrigins = ['https://deepsql.example.com'];
const corsHealth = await probe(`http://127.0.0.1:${localPort}`, profile);
check(
'an origin missing from CORS_ALLOWED_ORIGINS fails the probe',
!corsHealth.ok && corsHealth.status === 403,
`ok=${corsHealth.ok} status=${corsHealth.status}`,
);
corsAllowedOrigins = null;

// The listener must never be reachable from anything but loopback.
check(
'listener is bound to loopback only',
Expand Down
9 changes: 7 additions & 2 deletions desktop/src/main/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@
* It navigates a WebContentsView at the real DeepSQL origin (reached either
* directly over TLS or through an SSH tunnel), so the UI a user sees is always
* exactly the version their VM is running. There is no bundle/backend skew to
* manage, and cookies/CORS behave the same as they do in a browser because the
* app content is served from a single origin (the frontend nginx in
* manage, and cookies behave the same as they do in a browser because the app
* content is served from a single origin (the frontend nginx in
* docker/nginx/default.conf already fronts /api and /agent-api).
*
* CORS is the one thing that does *not* come for free: over a tunnel that single
* origin is http://127.0.0.1:<sticky port>, which the VM's CORS_ALLOWED_ORIGINS
* has to allow. See the note in probe.js — the probe sends an Origin header so a
* missing entry is caught here rather than at the user's first login attempt.
*/

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

const http = require('node:http');
Expand Down Expand Up @@ -47,7 +62,14 @@ function probe(origin, profile, { timeoutMs = DEFAULT_TIMEOUT_MS, path = HEALTH_
try {
options = {
method: 'GET',
headers: { Accept: 'application/json', 'User-Agent': 'DeepSQL-Desktop' },
headers: {
Accept: 'application/json',
'User-Agent': 'DeepSQL-Desktop',
// `url.origin` rather than the caller's string: it is normalised
// (default ports dropped, no trailing slash) exactly as a browser
// would send it, so the check matches what Chromium does next.
Origin: url.origin,
},
...(isHttps ? tls.nodeTlsOptions(profile) : {}),
};
} catch (err) {
Expand Down
21 changes: 19 additions & 2 deletions desktop/src/main/transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ class Transport extends EventEmitter {
const health = await probe(origin, profile);
if (!health.ok) {
if (tunnel) await tunnel.stop();
throw new TunnelError('unreachable', explainProbeFailure(profile, health));
throw new TunnelError(
health.status === 403 ? 'cors-rejected' : 'unreachable',
explainProbeFailure(profile, origin, health),
);
}

// First successful TLS handshake in pinned/insecure mode establishes the pin.
Expand Down Expand Up @@ -192,7 +195,21 @@ class Transport extends EventEmitter {
* `Host: 127.0.0.1:<port>`. Say so, because "HTTP 404" on its own sends people
* looking at the backend.
*/
function explainProbeFailure(profile, health) {
function explainProbeFailure(profile, origin, health) {
// 403 is its own diagnosis and applies to both transports: the probe sends an
// Origin header, so the one thing that rejects a *reachable, healthy* DeepSQL
// with 403 is its CORS allowlist. Checked before the tunnel branch below,
// which would otherwise blame the remote port for a backend that answered
// perfectly well.
if (health.status === 403) {
return (
`DeepSQL is running and reachable, but its backend refused the origin ${origin} ` +
'(HTTP 403, "Invalid CORS request"). Add that origin to CORS_ALLOWED_ORIGINS on the ' +
'VM and restart the backend. A port wildcard is the durable form, because the local ' +
'port changes: `CORS_ALLOWED_ORIGINS=https://your-host,http://127.0.0.1:*,' +
'http://localhost:*`. Left unfixed, DeepSQL would load but every login would fail.'
);
}
if (profile.transport !== 'tunnel' || !health.status) return health.detail;
const { remoteHost, remotePort } = profile.ssh;
return (
Expand Down
8 changes: 6 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,12 @@ services:
ENCRYPTION_KEYS: ${ENCRYPTION_KEYS:-}
ENCRYPTION_KEY_ID: ${ENCRYPTION_KEY_ID:-}

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

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