From b76b1c576fc31c675bf7a551aa68e6eba5ac370d Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Fri, 9 Oct 2026 08:48:38 +0200 Subject: [PATCH 1/4] Fix authentication return paths under a proxy prefix --- app/__init__.py | 3 ++- app/auth_redirects.py | 16 ++++++++++++++++ app/github_auth_routes.py | 5 +++-- app/oidc_routes.py | 11 ++++++----- app/recovery_routes.py | 3 ++- app/totp_routes.py | 3 ++- app/webauthn_routes.py | 3 ++- static/js/auth.js | 4 ++-- static/js/webauthn.js | 2 +- 9 files changed, 36 insertions(+), 14 deletions(-) create mode 100644 app/auth_redirects.py diff --git a/app/__init__.py b/app/__init__.py index 07f679d9..2e0fa6ce 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,4 @@ +from .auth_redirects import public_continuation from flask import (Flask, abort, current_app, flash, jsonify, redirect, render_template, request, session, url_for) from flask_socketio import SocketIO @@ -953,7 +954,7 @@ def login(): ) pending = consume_pending(token, binding) finalize_login(pending, methods=['password']) - return redirect(pending.continuation) + return redirect(public_continuation(pending.continuation)) else: log_login_attempt(username, False, client_ip, request.user_agent.string) flash(error, 'error') diff --git a/app/auth_redirects.py b/app/auth_redirects.py new file mode 100644 index 00000000..ea83ef04 --- /dev/null +++ b/app/auth_redirects.py @@ -0,0 +1,16 @@ +"""Public, same-origin return paths for completed authentication.""" +from urllib.parse import urlsplit + +from flask import request + +from .auth_assurance import _safe_continuation + + +def public_continuation(value): + """Add the trusted request prefix once, preserving the query string.""" + candidate = _safe_continuation(value) + prefix = request.script_root.rstrip('/') + path = urlsplit(candidate).path + if not prefix or path == prefix or path.startswith(prefix + '/'): + return candidate + return prefix + candidate diff --git a/app/github_auth_routes.py b/app/github_auth_routes.py index 4a9323a9..7ffc4266 100644 --- a/app/github_auth_routes.py +++ b/app/github_auth_routes.py @@ -7,6 +7,7 @@ import re import secrets +from .auth_redirects import public_continuation from flask import Blueprint, abort, jsonify, redirect, render_template, request, session from flask_login import current_user, login_required from sqlalchemy.exc import IntegrityError @@ -223,7 +224,7 @@ def _complete_link(intent, profile): 'GITHUB_IDENTITY_LINKED', user=current_user.username, github_user_id=profile.user_id, ) - return redirect(intent.continuation) + return redirect(public_continuation(intent.continuation)) def _complete_step_up(intent, profile): @@ -306,7 +307,7 @@ def _complete_login(intent, profile, settings): 'GITHUB_LOGIN_SUCCESS', user=user.username, github_user_id=profile.user_id, ) - return redirect(pending.continuation) + return redirect(public_continuation(pending.continuation)) @github_auth_blueprint.get('/auth/github/callback') diff --git a/app/oidc_routes.py b/app/oidc_routes.py index e630a1e5..4b3686c9 100644 --- a/app/oidc_routes.py +++ b/app/oidc_routes.py @@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlsplit from authlib.integrations.flask_client import OAuth +from .auth_redirects import public_continuation from flask import ( Blueprint, abort, @@ -232,7 +233,7 @@ def _complete_oidc_self_link(intent, issuer, subject): issuer=issuer, identity_id=existing.id, ) - return redirect(intent.continuation) + return redirect(public_continuation(intent.continuation)) log_security_event( "OIDC_IDENTITY_LINK_COLLISION", level=logging.WARNING, @@ -308,7 +309,7 @@ def _complete_oidc_self_link(intent, issuer, subject): issuer=issuer, source="self_service", ) - return redirect(intent.continuation) + return redirect(public_continuation(intent.continuation)) def begin_oidc_step_up( @@ -609,7 +610,7 @@ def oidc_callback(): assurance=assurance.level.value, result="approved", ) - return redirect(intent.continuation) + return redirect(public_continuation(intent.continuation)) if ( not current_user.is_admin or assurance.level is AssuranceLevel.BASIC @@ -636,7 +637,7 @@ def oidc_callback(): action=intent.step_up_action, assurance=assurance.level.value, ) - return redirect(intent.continuation) + return redirect(public_continuation(intent.continuation)) local_mfa_methods = None if user.mfa_enabled and assurance.level is AssuranceLevel.BASIC: @@ -702,7 +703,7 @@ def oidc_callback(): issuer=issuer, assurance=assurance.level.value, ) - return redirect(pending.continuation) + return redirect(public_continuation(pending.continuation)) @oidc_blueprint.post("/admin/api/users//oidc-link") diff --git a/app/recovery_routes.py b/app/recovery_routes.py index f24047ac..96130ca0 100644 --- a/app/recovery_routes.py +++ b/app/recovery_routes.py @@ -3,6 +3,7 @@ import logging import config +from .auth_redirects import public_continuation from flask import Blueprint, abort, jsonify, request, session from flask_login import current_user, login_required from werkzeug.exceptions import RequestEntityTooLarge @@ -159,7 +160,7 @@ def complete_pending_with_recovery(): return jsonify({ "ok": True, "recovery_required": True, - "continuation": "/security", + "continuation": public_continuation("/security"), }) diff --git a/app/totp_routes.py b/app/totp_routes.py index a5fd1043..50197990 100644 --- a/app/totp_routes.py +++ b/app/totp_routes.py @@ -4,6 +4,7 @@ import secrets import config +from .auth_redirects import public_continuation from flask import Blueprint, abort, jsonify, request, session from flask_login import current_user, login_required @@ -262,7 +263,7 @@ def verify_totp_login(): ) except PendingAuthenticationError: return jsonify({"error": "Pending authentication is invalid"}), 401 - return jsonify({"ok": True, "continuation": continuation}) + return jsonify({"ok": True, "continuation": public_continuation(continuation)}) @totp_blueprint.post("/api/totp/disable") diff --git a/app/webauthn_routes.py b/app/webauthn_routes.py index da84fd24..31e8ffb6 100644 --- a/app/webauthn_routes.py +++ b/app/webauthn_routes.py @@ -7,6 +7,7 @@ from datetime import datetime, timezone from threading import Lock +from .auth_redirects import public_continuation from flask import Blueprint, abort, jsonify, request, session from flask_login import current_user, login_required from webauthn import ( @@ -512,5 +513,5 @@ def verify_authentication(): return jsonify({"error": "Passkey authentication failed"}), 401 response = {"ok": True} if pending is not None: - response["continuation"] = continuation + response["continuation"] = public_continuation(continuation) return jsonify(response) diff --git a/static/js/auth.js b/static/js/auth.js index 5c4fe486..aff1e838 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -407,7 +407,7 @@ 'The code could not be verified.' )); } - window.location.assign(root + (data.continuation || '/')); + window.location.assign(data.continuation || root + '/'); } catch (requestError) { error.textContent = requestError.message; error.classList.remove('hidden'); @@ -534,7 +534,7 @@ 'Recovery authentication failed.' )); } - window.location.assign(root + (data.continuation || '/security')); + window.location.assign(data.continuation || root + '/security'); } catch (requestError) { error.textContent = requestError.message; error.classList.remove('hidden'); diff --git a/static/js/webauthn.js b/static/js/webauthn.js index 3ce363d6..35cc6796 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -878,7 +878,7 @@ method: 'POST', body: { credential: serializeCredential(credential) } }); - window.location.assign(root + (result.continuation || '/')); + window.location.assign(result.continuation || root + '/'); return true; }; const configuredOrigin = document.querySelector( From deb1d2068569aa0233810b11bcad1d0bfda15a85 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Fri, 9 Oct 2026 08:48:39 +0200 Subject: [PATCH 2/4] Apply trusted proxy headers before Socket.IO dispatch --- app/__init__.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 2e0fa6ce..2556abe8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -623,16 +623,6 @@ def invalidate_ldap_browser_access(reason): }), 403 abort(403) - trusted_proxies = config.TRUSTED_PROXIES - if trusted_proxies > 0: - app.wsgi_app = ProxyFix( - app.wsgi_app, - x_for=trusted_proxies, - x_proto=trusted_proxies, - x_host=trusted_proxies, - x_prefix=trusted_proxies - ) - log_info("ProxyFix enabled") if not config.DEBUG: if not os.environ.get('SECRET_KEY'): @@ -753,6 +743,18 @@ def enforce_security_feature_gate(): ) _install_engineio_admission(app) + trusted_proxies = config.TRUSTED_PROXIES + if trusted_proxies > 0: + app.wsgi_app = ProxyFix( + app.wsgi_app, + x_for=trusted_proxies, + x_proto=trusted_proxies, + x_host=trusted_proxies, + x_prefix=trusted_proxies + ) + log_info("ProxyFix enabled") + + @app.after_request def add_security_headers(response): """Add comprehensive security headers to all responses.""" From 07378fd04badd6292a9bca178f8fe633e9615e8c Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Fri, 9 Oct 2026 08:48:40 +0200 Subject: [PATCH 3/4] Add bounded connection diagnostics and proxy deployment coverage --- app/__init__.py | 26 +- .../Reverse-Proxy-and-Subfolder-Deployment.md | 106 +++++++- static/js/app.js | 21 +- tests/js/auth-redirects.test.js | 22 ++ tests/js/socket-connection-notice.test.js | 30 +++ tests/js/socket-protocol.test.js | 2 +- tests/test_proxy_prefix_regression.py | 250 ++++++++++++++++++ tests/test_threaded_runtime.py | 3 +- 8 files changed, 443 insertions(+), 17 deletions(-) create mode 100644 tests/js/auth-redirects.test.js create mode 100644 tests/js/socket-connection-notice.test.js create mode 100644 tests/test_proxy_prefix_regression.py diff --git a/app/__init__.py b/app/__init__.py index 2556abe8..7673fae7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -201,7 +201,7 @@ def _engineio_admission_is_current(app, environ, expected_user_id): def _install_engineio_admission(app): """Bound transports before they can retain Engine.IO state or threads.""" - from threading import Event + from threading import Event, Lock from .socket_capacity import SocketCapacityRegistry @@ -216,6 +216,22 @@ def _install_engineio_admission(app): capacity = SocketCapacityRegistry() app.extensions['engineio_socket_capacity'] = capacity + + diagnostic_lock = Lock() + last_rejection = {} + + def report_rejection(reason): + # At most one message per fixed reason per minute, across all clients. + now = time.monotonic() + with diagnostic_lock: + if now - last_rejection.get(reason, float('-inf')) < 60: + return + last_rejection[reason] = now + try: + log_warning('Engine.IO transport admission rejected', reason=reason) + except Exception: + pass + # Bind admission state to this exact Engine.IO server. Delayed cleanup # workers must never consult a newer app/server created by a test or reload. engineio_server._webssh_socket_capacity = capacity @@ -316,12 +332,16 @@ def handle_engineio_connect(engineio_sid, environ): ) try: user_id = _engineio_admission_user(app, environ) - if user_id is None or not capacity.reserve( + if user_id is None: + report_rejection('authentication') + return False + if not capacity.reserve( user_id, engineio_sid, config.MAX_SOCKET_CONNECTIONS, config.MAX_SOCKET_CONNECTIONS_PER_USER, ): + report_rejection('capacity') return False if ( not _engineio_admission_is_current(app, environ, user_id) @@ -329,6 +349,7 @@ def handle_engineio_connect(engineio_sid, environ): or capacity.is_terminal(engineio_sid) ): capacity.release(engineio_sid) + report_rejection('authentication_changed') return False result = original_connect(engineio_sid, environ) except Exception as error: @@ -340,7 +361,6 @@ def handle_engineio_connect(engineio_sid, environ): log_error( 'Engine.IO transport admission failed closed', error_type=type(error).__name__, - sid=engineio_sid, ) except Exception: # Logging must never turn a controlled rejection into a diff --git a/docs/wiki/Reverse-Proxy-and-Subfolder-Deployment.md b/docs/wiki/Reverse-Proxy-and-Subfolder-Deployment.md index 6c45c672..deffd8fa 100644 --- a/docs/wiki/Reverse-Proxy-and-Subfolder-Deployment.md +++ b/docs/wiki/Reverse-Proxy-and-Subfolder-Deployment.md @@ -132,22 +132,90 @@ server.example.com { ### Apache subfolder -Enable `mod_alias` in addition to the modules used above. Redirect the missing -trailing slash and forward the public prefix explicitly: +Use Apache httpd 2.4.47+ with `mod_alias`, `mod_proxy`, `mod_proxy_http`, +`mod_headers`, and `mod_ssl`. This is a complete TLS virtual host; replace the +hostname and certificate paths for your installation: ```apache -RedirectMatch permanent "^/webssh$" "/webssh/" + + ServerName server.example.com + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/server.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/server.example.com/privkey.pem - + ProxyRequests Off + ProxyPreserveHost On + ProxyAddHeaders On + ProxyTimeout 3600 + RequestHeader unset X-Forwarded-For + RequestHeader set X-Forwarded-Host "server.example.com" + RequestHeader set X-Forwarded-Proto "https" RequestHeader set X-Forwarded-Prefix "/webssh" - -ProxyPass "/webssh/" "http://127.0.0.1:5000/" upgrade=websocket -ProxyPassReverse "/webssh/" "http://127.0.0.1:5000/" + RedirectMatch permanent "^/webssh$" "/webssh/" + ProxyPass "/webssh/" "http://127.0.0.1:5000/" upgrade=websocket timeout=3600 + ProxyPassReverse "/webssh/" "http://127.0.0.1:5000/" + ``` -Keep the trailing slash on both proxy paths. Set `APPLICATION_ROOT=/webssh` in -WebSSH as shown above. +Apache adds the directly connected client address after removing untrusted +incoming forwarding values. This example assumes one proxy layer. Do not add +another catch-all `ProxyPass "/"` from the root example to this virtual host. + +### HAProxy subfolder + +This example terminates TLS at HAProxy and forwards to one WebSSH instance. +Use the application settings above and a PEM containing the certificate chain +and private key at the configured path. HTTP/1.1 supports both Socket.IO polling +and its WebSocket upgrade through the same backend: + +```haproxy +global + maxconn 1024 + +defaults + mode http + timeout connect 5s + timeout client 60s + timeout server 60s + timeout tunnel 1h + +frontend webssh_https + bind :443 ssl crt /etc/haproxy/certs/server.example.com.pem alpn http/1.1 + acl webssh_bare path /webssh + acl webssh_path path_beg /webssh/ + acl has_query query -m len gt 0 + http-request redirect code 308 location /webssh/?%[query] if webssh_bare has_query + http-request redirect code 308 location /webssh/ if webssh_bare !has_query + http-request deny deny_status 404 unless webssh_path + default_backend webssh + +backend webssh + http-request set-header X-Forwarded-For %[src] + http-request set-header X-Forwarded-Host %[req.hdr(Host)] + http-request set-header X-Forwarded-Proto https + http-request set-header X-Forwarded-Prefix /webssh + http-request set-path %[path,regsub(^/webssh/,/)] + server webssh 127.0.0.1:5000 +``` + +`set-path` changes only the path; Socket.IO query parameters such as `EIO`, +`transport`, and `sid` remain intact. Do not replace the complete URI or route +WebSocket upgrades through a different prefix mapping. Forwarding headers are +overwritten because this example has exactly one trusted proxy layer. + +For `/tools/webssh`, replace every `/webssh` prefix in the selected example +and set `APPLICATION_ROOT=/tools/webssh`. `CORS_ORIGINS` remains the public +origin without a path. `/webssh-other` is not part of `/webssh/`. + +Start validation with exactly one Gunicorn `gthread` worker, as in the +Dockerfile. Keep the existing cookie and authentication settings. A working +health check alone does not establish that authenticated Socket.IO works. + +These configurations must be syntax-checked against the installed proxy version +and verified with the authenticated checks below before deployment. References: +[Apache ProxyPass](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#proxypass) +and [HAProxy configuration manual](https://www.haproxy.com/documentation/haproxy-configuration-manual/latest/). ## Containerized proxy @@ -198,3 +266,23 @@ curl -fsS https://server.example.com/webssh/ready Complete the check in a browser by logging in, opening a terminal, resizing it, and transferring a small file. + +### Authenticated transport diagnostics + +Log in normally, then inspect the browser Network panel for the public path +`/webssh/socket.io/?EIO=4&transport=polling` (or your configured prefix). A +successful initial polling response starts with an Engine.IO open packet (`0`). +When upgrading, the WebSocket request should receive `101 Switching Protocols`. +An anonymous `curl` request can legitimately be rejected: WebSSH requires an +authenticated session before admitting an Engine.IO transport. + +For a failed request, record the status, sanitized response text, transport, +timestamp, and matching server/proxy log event. A 404 can indicate a prefix +mapping error; a 400 can indicate an origin or protocol error; a 401 can indicate +an admission rejection. None of these codes alone proves the cause. Do not share +cookies, authorization headers, credentials, session IDs, or unredacted HAR files. + +Check password, configured second factors and identity providers, session expiry +and return to the original page, terminal input/resize, upload/download, idle +connections and reconnect. Compare HTTP and Socket.IO scheme, host, prefix, and +client address. Record the WebSSH revision and proxy configuration with results. diff --git a/static/js/app.js b/static/js/app.js index 077e2a7e..4a07420c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -186,9 +186,26 @@ socket.on(socketProtocol.MISMATCH_EVENT, data => { socketProtocolMismatch.handleMismatch(data); }); + let connectionErrorNotice = null; + let connectionErrorReported = false; socket.on('connect_error', error => { - if (error?.data?.code !== 'socket_protocol_mismatch') return; - socketProtocolMismatch.handleMismatch(error.data); + if (error?.data?.code === 'socket_protocol_mismatch') { + socketProtocolMismatch.handleMismatch(error.data); + return; + } + if (connectionErrorReported) return; + connectionErrorReported = true; + connectionErrorNotice = showNotification({ + message: 'Cannot connect to WebSSH. Check your network and sign-in session. ' + + 'If this continues, check the proxy path, public origin and WebSocket settings.', + type: 'error', + persistent: true, + }); + }); + socket.on('connect', () => { + connectionErrorNotice?.(); + connectionErrorNotice = null; + connectionErrorReported = false; }); window.ModalManager = { diff --git a/tests/js/auth-redirects.test.js b/tests/js/auth-redirects.test.js new file mode 100644 index 00000000..e4b883d6 --- /dev/null +++ b/tests/js/auth-redirects.test.js @@ -0,0 +1,22 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +for (const file of ['auth.js', 'webauthn.js']) { + const source = fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'); + const statements = source.match(/window\.location\.assign\([^;]*continuation[^;]*;/g); + for (const [index, statement] of statements.entries()) { + for (const root of ['', '/webssh', '/tools/webssh']) { + test(`${file} continuation ${index} retains public path at ${root || '/'}`, () => { + let assigned; + const continuation = root + '/admin?tab=users'; + vm.runInNewContext(statement, { + root, data: {continuation}, result: {continuation}, + window: {location: {assign(value) { assigned = value; }}}, + }); + assert.equal(assigned, continuation); + }); + } + } +} diff --git a/tests/js/socket-connection-notice.test.js b/tests/js/socket-connection-notice.test.js new file mode 100644 index 00000000..54d6af85 --- /dev/null +++ b/tests/js/socket-connection-notice.test.js @@ -0,0 +1,30 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const source = fs.readFileSync(path.join(__dirname, '../../static/js/app.js'), 'utf8'); +test('connection errors are bounded, sanitized and reset after connection', () => { + const events = {}; + const notices = []; + let mismatch = 0; + const start = source.indexOf(" socket.on('connect_error'"); + const noticeStart = source.indexOf(' let connectionErrorNotice'); + const end = source.indexOf(' window.ModalManager', start); + vm.runInNewContext(source.slice(noticeStart < 0 ? start : noticeStart, end), { + socket: {on(event, handler) { events[event] = handler; }}, + showNotification(value) { notices.push(value); return () => {}; }, + socketProtocolMismatch: {handleMismatch() { mismatch++; }}, + }); + events.connect_error({message: 'secret-cookie', description: 'private-path'}); + events.connect_error({message: 'secret-cookie'}); + assert.equal(notices.length, 1); + assert.doesNotMatch(JSON.stringify(notices), /secret-cookie|private-path/); + assert.match(notices[0].message, /proxy|session/i); + events.connect(); + events.connect_error({message: 'another failure'}); + assert.equal(notices.length, 2); + events.connect_error({data: {code: 'socket_protocol_mismatch'}}); + assert.equal(mismatch, 1); + assert.equal(notices.length, 2); +}); diff --git a/tests/js/socket-protocol.test.js b/tests/js/socket-protocol.test.js index 5d4f23d8..148aba40 100644 --- a/tests/js/socket-protocol.test.js +++ b/tests/js/socket-protocol.test.js @@ -346,7 +346,7 @@ test('authenticated page sends the revision and loads protocol before app', () = )); assert.ok(appSource.includes("socket.on('connect_error'")); assert.ok(appSource.includes( - "error?.data?.code !== 'socket_protocol_mismatch'", + "error?.data?.code === 'socket_protocol_mismatch'", )); assert.ok( template.indexOf("filename='js/socket-protocol.js'") diff --git a/tests/test_proxy_prefix_regression.py b/tests/test_proxy_prefix_regression.py new file mode 100644 index 00000000..0987857d --- /dev/null +++ b/tests/test_proxy_prefix_regression.py @@ -0,0 +1,250 @@ +"""Regression probes for reverse proxy prefixes (issue 257).""" +import pytest + +@pytest.fixture(params=['', '/webssh', '/tools/webssh']) +def proxy_app(request, monkeypatch): + monkeypatch.setenv('APPLICATION_ROOT', request.param or '/') + monkeypatch.setenv('TRUSTED_PROXIES', '1') + monkeypatch.setenv('CORS_ORIGINS', 'https://public.example') + import config + monkeypatch.setattr(config, 'APPLICATION_ROOT', '/', raising=False) + app = request.getfixturevalue('app') + return app, request.param + +def headers(prefix): + return {'X-Forwarded-Prefix': prefix, 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'public.example', 'X-Forwarded-For': '203.0.113.9', + 'Origin': 'https://public.example'} + +def login(app, prefix): + from app.auth import register_user + with app.app_context(): + user, error = register_user('prefix_user', 'password123') + assert error is None + client = app.test_client() + response = client.post('/login', data={'username': 'prefix_user', 'password': 'password123'}, + headers=headers(prefix), base_url='http://backend.local') + cookie = client.get_cookie(app.config['SESSION_COOKIE_NAME'], domain='backend.local', path=prefix or '/') + if cookie and prefix: + client.set_cookie(cookie.key, cookie.value, domain='backend.local', path='/') + return client, response + +def test_password_default_redirect(proxy_app): + app, prefix = proxy_app + client, response = login(app, prefix) + assert response.status_code == 302 + assert response.location == prefix + '/' + +def test_engineio_observes_same_public_context(proxy_app, monkeypatch): + import app as app_module + app, prefix = proxy_app + client, response = login(app, prefix) + observed = [] + original = app_module._engineio_admission_user + def capture(app, environ): + observed.append((environ.get('SCRIPT_NAME'), environ.get('wsgi.url_scheme'), + environ.get('HTTP_HOST'), environ.get('REMOTE_ADDR'))) + return original(app, environ) + monkeypatch.setattr(app_module, '_engineio_admission_user', capture) + response = client.get('/socket.io/?EIO=4&transport=polling', headers=headers(prefix), + base_url='http://backend.local', environ_overrides={'REMOTE_ADDR': '10.0.0.2'}) + assert response.status_code == 200, response.get_data(as_text=True) + assert observed == [(prefix, 'https', 'public.example', '203.0.113.9')] + + +@pytest.mark.parametrize('target, expected', [ + ('/', '/'), ('/admin?tab=users', '/admin?tab=users'), + ('/webssh-other?q=1', '/webssh-other?q=1'), + ('//evil.example', '/'), ('https://evil.example', '/'), + ('/bad\\path', '/'), ('/bad#fragment', '/'), ('/bad\npath', '/'), +]) +def test_public_continuation(proxy_app, target, expected): + from app.auth_redirects import public_continuation + app, prefix = proxy_app + with app.test_request_context('/', environ_overrides={'SCRIPT_NAME': prefix}): + assert public_continuation(target) == prefix + expected + assert public_continuation(prefix + expected) == prefix + expected + + +def test_admission_diagnostics_are_bounded_and_do_not_log_session_data(proxy_app, monkeypatch): + import app as app_module + app, prefix = proxy_app + events = [] + monkeypatch.setattr(app_module, 'log_warning', lambda message, **data: events.append((message, data))) + client = app.test_client() + for _ in range(3): + response = client.get('/socket.io/?EIO=4&transport=polling', headers=headers(prefix)) + assert response.status_code == 401 + assert events == [('Engine.IO transport admission rejected', {'reason': 'authentication'})] + + +@pytest.mark.parametrize('trust', [0, 1, 2]) +def test_http_and_engineio_apply_the_same_trust_depth(request, monkeypatch, trust): + from flask import jsonify, request as http_request + import config + import app as app_module + monkeypatch.setenv('TRUSTED_PROXIES', str(trust)) + monkeypatch.setenv('APPLICATION_ROOT', '') + monkeypatch.setattr(config, 'APPLICATION_ROOT', '/', raising=False) + app = request.getfixturevalue('app') + observed = [] + def context(environ): + return [environ.get(key) for key in ('SCRIPT_NAME', 'wsgi.url_scheme', 'HTTP_HOST', 'REMOTE_ADDR')] + @app.route('/proxy-context') + def proxy_context(): + return jsonify(context(http_request.environ)) + def capture(_app, environ): + observed.append(context(environ)) + return None + monkeypatch.setattr(app_module, '_engineio_admission_user', capture) + forwarded = { + 'X-Forwarded-Prefix': '/attacker, /tools/webssh, /inner', + 'X-Forwarded-Proto': 'ftp, https, http', + 'X-Forwarded-Host': 'attacker.example, public.example, inner.example', + 'X-Forwarded-For': '192.0.2.1, 203.0.113.9, 10.0.0.3', + } + client = app.test_client() + kwargs = {'headers': forwarded, 'base_url': 'http://backend.local', + 'environ_overrides': {'REMOTE_ADDR': '10.0.0.2'}} + http = client.get('/proxy-context', **kwargs) + client.get('/socket.io/?EIO=4&transport=polling', **kwargs) + expected = [ + ['', 'http', 'backend.local', '10.0.0.2'], + ['/inner', 'http', 'inner.example', '10.0.0.3'], + ['/tools/webssh', 'https', 'public.example', '203.0.113.9'], + ][trust] + assert http.get_json() == expected + assert observed == [expected] + + +@pytest.fixture +def proxy_browser(proxy_app): + from flask.testing import FlaskClient + app, prefix = proxy_app + class ProxyClient(FlaskClient): + def open(self, *args, **kwargs): + kwargs.setdefault('headers', {}).update(headers(prefix)) + kwargs.setdefault('base_url', 'http://backend.local') + return super().open(*args, **kwargs) + + def session_transaction(self, *args, **kwargs): + kwargs.setdefault('base_url', 'http://backend.local') + return super().session_transaction(*args, **kwargs) + + def _add_cookies_to_wsgi(self, environ): + # The browser selects cookies on the public path before the proxy + # strips it. Preserve the production cookie path in these tests. + path = environ['PATH_INFO'] + environ['PATH_INFO'] = prefix + path + try: + super()._add_cookies_to_wsgi(environ) + finally: + environ['PATH_INFO'] = path + app.test_client_class = ProxyClient + return app.test_client() + + +@pytest.mark.parametrize('factor', ['totp', 'recovery', 'passkey']) +def test_second_factor_returns_a_public_continuation(proxy_app, proxy_browser, monkeypatch, factor): + import base64 + import config + import pyotp + from types import SimpleNamespace + from app.auth import register_user + from app.models import User, TOTPAuthenticator, WebAuthnCredential, SecurityFeatureState, db + from app.mfa_crypto import encrypt_totp_secret + from app.recovery_service import generate_codes + import app.webauthn_routes as webauthn_routes + app, prefix = proxy_app + secret = pyotp.random_base32() + credential_id = b'prefix-passkey' + monkeypatch.setattr(config, 'TOTP_ENABLED', True) + monkeypatch.setattr(config, 'WEBAUTHN_ENABLED', True) + with app.app_context(): + user, error = register_user('factor_user', 'password123') + assert error is None + user.mfa_enabled = True + db.session.merge(SecurityFeatureState(feature='totp', enabled=True)) + db.session.merge(SecurityFeatureState(feature='passkey', enabled=True)) + db.session.add(TOTPAuthenticator(user_id=user.id, encrypted_secret=encrypt_totp_secret(user.id, secret), label='Test', active=True)) + db.session.add(WebAuthnCredential(user_id=user.id, credential_id=credential_id, public_key=b'test-key', sign_count=0, transports='[]', name='Test')) + code = generate_codes(user.id, count=1)[0] + db.session.commit() + client = proxy_browser + response = client.post('/login', query_string={'next': prefix + '/admin?tab=users'}, + data={'username': 'factor_user', 'password': 'password123'}) + assert response.status_code == 200 + if factor == 'totp': + response = client.post('/api/totp/auth/verify', json={'code': pyotp.TOTP(secret).now()}) + elif factor == 'recovery': + response = client.post('/api/auth/recovery', json={'code': code}) + else: + monkeypatch.setattr(webauthn_routes, 'verify_authentication_response', + lambda **kwargs: SimpleNamespace(new_sign_count=1)) + assert client.post('/api/webauthn/auth/options', json={}).status_code == 200 + encoded = base64.urlsafe_b64encode(credential_id).decode().rstrip('=') + response = client.post('/api/webauthn/auth/verify', json={'credential': {'id': encoded}}) + assert response.status_code == 200, response.get_data(as_text=True) + expected = prefix + ('/security' if factor == 'recovery' else '/admin?tab=users') + assert response.get_json()['continuation'] == expected + + +def test_github_callback_preserves_public_prefix(proxy_app, proxy_browser, monkeypatch): + from tests.test_github_auth_routes import _create_user, _configure, _provider, _begin_login + from app.models import GitHubIdentity, db + app, prefix = proxy_app + admin_id = _create_user(app, 'github_admin', is_admin=True) + user_id = _create_user(app, 'github_user') + from app.github_auth_service import update_settings + with app.app_context(): + update_settings({'enabled': True, 'client_id': 'Iv1234567890abcdef', + 'client_secret': 'a' * 40, + 'redirect_uri': 'https://public.example' + prefix + '/auth/github/callback', + 'auto_provision': False, 'allowed_orgs': []}, admin_id) + with app.app_context(): + db.session.add(GitHubIdentity(user_id=user_id, github_user_id='12345', login='old-name')) + db.session.commit() + state = _begin_login(proxy_browser) + _provider(monkeypatch) + response = proxy_browser.get('/auth/github/callback', query_string={'code': 'code', 'state': state}) + assert response.status_code == 302 + assert response.location == prefix + '/' + + +def test_oidc_callback_preserves_public_prefix(proxy_app, proxy_browser, monkeypatch): + import config + import app.oidc_routes as routes + from tests.test_oidc_routes import _create_user, _prepare_oidc_callback, _signed_provider + app, prefix = proxy_app + monkeypatch.setattr(config, 'OIDC_ENABLED', True) + monkeypatch.setattr(config, 'OIDC_ISSUER', 'https://issuer.example') + monkeypatch.setattr(config, 'OIDC_ALLOWED_SUBJECTS', set()) + monkeypatch.setattr(config, 'OIDC_ALLOWED_DOMAINS', set()) + user_id = _create_user(app, 'oidc_user') + _prepare_oidc_callback(app, proxy_browser, user_id, state='prefix-state', subject='prefix-subject') + monkeypatch.setattr(routes, '_client', lambda: _signed_provider('prefix-state', + {'iss': 'https://issuer.example', 'sub': 'prefix-subject'})) + response = proxy_browser.get('/oidc/callback?state=prefix-state') + assert response.status_code == 302 + assert response.location == prefix + '/' + + + +def test_wrong_origin_is_rejected_without_weakening_authentication(proxy_app): + app, prefix = proxy_app + client, _response = login(app, prefix) + forwarded = headers(prefix) + forwarded['Origin'] = 'https://wrong.example' + response = client.get('/socket.io/?EIO=4&transport=polling', headers=forwarded, + base_url='http://backend.local') + assert response.status_code == 400 + + +def test_unstripped_prefix_is_not_a_backend_socketio_route(proxy_app): + app, prefix = proxy_app + if not prefix: + return + client, _response = login(app, prefix) + response = client.get(prefix + '/socket.io/?EIO=4&transport=polling', headers=headers(prefix), + base_url='http://backend.local') + assert response.status_code == 404 diff --git a/tests/test_threaded_runtime.py b/tests/test_threaded_runtime.py index 87a477f0..5f0fe2d0 100644 --- a/tests/test_threaded_runtime.py +++ b/tests/test_threaded_runtime.py @@ -169,8 +169,7 @@ def fail_admission(_app, _environ): message, details = logged_errors[0] assert message == 'Engine.IO transport admission failed closed' assert details['error_type'] == 'RuntimeError' - assert isinstance(details['sid'], str) - assert details['sid'] + assert 'sid' not in details def test_engineio_rejects_browser_session_after_epoch_rotation(app): From ab7eca8cbb80cffb609d9885555ad349c6310389 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Fri, 9 Oct 2026 09:22:49 +0200 Subject: [PATCH 4/4] Reject dot segments in authentication return paths --- app/auth_redirects.py | 4 ++++ tests/test_proxy_prefix_regression.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/auth_redirects.py b/app/auth_redirects.py index ea83ef04..3d1f2173 100644 --- a/app/auth_redirects.py +++ b/app/auth_redirects.py @@ -11,6 +11,10 @@ def public_continuation(value): candidate = _safe_continuation(value) prefix = request.script_root.rstrip('/') path = urlsplit(candidate).path + # Browsers normalize literal and percent-encoded dot segments in URLs. + if any(segment.lower().replace('%2e', '.') in {'.', '..'} + for segment in path.split('/')): + return prefix + '/' if not prefix or path == prefix or path.startswith(prefix + '/'): return candidate return prefix + candidate diff --git a/tests/test_proxy_prefix_regression.py b/tests/test_proxy_prefix_regression.py index 0987857d..46f5781f 100644 --- a/tests/test_proxy_prefix_regression.py +++ b/tests/test_proxy_prefix_regression.py @@ -248,3 +248,25 @@ def test_unstripped_prefix_is_not_a_backend_socketio_route(proxy_app): response = client.get(prefix + '/socket.io/?EIO=4&transport=polling', headers=headers(prefix), base_url='http://backend.local') assert response.status_code == 404 + +@pytest.mark.parametrize('prefix', ['', '/webssh', '/tools/webssh']) +@pytest.mark.parametrize('segment', ['.', '..', '%2e', '%2E', '.%2e', '%2E.', '%2e%2E']) +@pytest.mark.parametrize('suffix', ['', '/admin?tab=users']) +def test_public_continuation_rejects_dot_segments(prefix, segment, suffix): + from flask import Flask + from app.auth_redirects import public_continuation + app = Flask(__name__) + with app.test_request_context('/', environ_overrides={'SCRIPT_NAME': prefix}): + for path in (f'/{segment}{suffix}', f'{prefix}/{segment}{suffix}'): + assert public_continuation(path) == prefix + '/' + + +@pytest.mark.parametrize('prefix', ['', '/webssh', '/tools/webssh']) +@pytest.mark.parametrize('path', ['/admin?next=../notes&name=%2e%2e', '/file.txt', '/.well-known', '/notes...']) +def test_public_continuation_preserves_non_segment_dots(prefix, path): + from flask import Flask + from app.auth_redirects import public_continuation + app = Flask(__name__) + with app.test_request_context('/', environ_overrides={'SCRIPT_NAME': prefix}): + assert public_continuation(path) == prefix + path + assert public_continuation(prefix + path) == prefix + path