Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
51 changes: 37 additions & 14 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -200,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

Expand All @@ -215,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
Expand Down Expand Up @@ -315,19 +332,24 @@ 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)
or capacity.owner(engineio_sid) != user_id
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:
Expand All @@ -339,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
Expand Down Expand Up @@ -622,16 +643,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'):
Expand Down Expand Up @@ -752,6 +763,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."""
Expand Down Expand Up @@ -953,7 +976,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')
Expand Down
20 changes: 20 additions & 0 deletions app/auth_redirects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""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
# 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 + '/'):
Comment thread
bifrost0x marked this conversation as resolved.
return candidate
return prefix + candidate
5 changes: 3 additions & 2 deletions app/github_auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Expand Down
11 changes: 6 additions & 5 deletions app/oidc_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand 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
Expand All @@ -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:
Expand Down Expand Up @@ -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/<int:user_id>/oidc-link")
Expand Down
3 changes: 2 additions & 1 deletion app/recovery_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -159,7 +160,7 @@ def complete_pending_with_recovery():
return jsonify({
"ok": True,
"recovery_required": True,
"continuation": "/security",
"continuation": public_continuation("/security"),
})


Expand Down
3 changes: 2 additions & 1 deletion app/totp_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion app/webauthn_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
106 changes: 97 additions & 9 deletions docs/wiki/Reverse-Proxy-and-Subfolder-Deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
<VirtualHost *:443>
ServerName server.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/server.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/server.example.com/privkey.pem

<Location "/webssh/">
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"
</Location>

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/"
</VirtualHost>
```

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

Expand Down Expand Up @@ -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.
Loading
Loading