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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ LDAP_ENABLED=false
LDAP_AUTO_PROVISION=false
LDAP_PROVIDER_ID=default
LDAP_URL=
LDAP_BACKUP_URL=
LDAP_BASE_DN=
LDAP_BIND_DN=
LDAP_BIND_PASSWORD_FILE=/run/webssh-auth/ldap_bind_password
Expand Down
57 changes: 47 additions & 10 deletions app/ldap_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import os
import ssl
import stat
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from urllib.parse import urlsplit

Expand Down Expand Up @@ -39,6 +39,7 @@ class LDAPSettings:
unique_id_attribute: str
connect_timeout: int
operation_timeout: int
backup_url: str = ''

@classmethod
def from_config(cls):
Expand All @@ -55,8 +56,15 @@ def from_config(cls):
unique_id_attribute=config.LDAP_UNIQUE_ID_ATTRIBUTE,
connect_timeout=config.LDAP_CONNECT_TIMEOUT,
operation_timeout=config.LDAP_OPERATION_TIMEOUT,
backup_url=getattr(config, 'LDAP_BACKUP_URL', ''),
)

def endpoints(self):
return tuple(filter(None, (self.url, self.backup_url)))

def for_url(self, url):
return replace(self, url=url, backup_url='')


@dataclass(frozen=True)
class LDAPIdentity:
Expand Down Expand Up @@ -239,6 +247,26 @@ class LDAPDirectory:
def __init__(self, settings=None, *, backend=None):
self.settings = settings or LDAPSettings.from_config()
self.backend = backend or BonsaiBackend()
self.active_url = None

def _endpoint_settings(self):
urls = list(self.settings.endpoints())
if self.active_url in urls:
urls.remove(self.active_url)
urls.insert(0, self.active_url)
return tuple(self.settings.for_url(url) for url in urls)

def _with_failover(self, operation):
last_error = None
for endpoint_settings in self._endpoint_settings():
try:
result = operation(endpoint_settings)
except LDAPUnavailable as exc:
last_error = exc
continue
self.active_url = endpoint_settings.url
return result
raise LDAPUnavailable('LDAP servers are unavailable') from last_error

def lookup(self, username):
normalized_username = str(username or '').strip()
Expand All @@ -249,10 +277,12 @@ def lookup(self, username):
)
bind_password = _read_secret(self.settings.bind_password_file)
try:
entries = self.backend.search_user(
self.settings,
bind_password,
filter_expression,
entries = self._with_failover(
lambda endpoint_settings: self.backend.search_user(
endpoint_settings,
bind_password,
filter_expression,
)
)
finally:
bind_password = None
Expand All @@ -278,15 +308,22 @@ def lookup(self, username):
def verify_password(self, distinguished_name, password):
if not password:
return False
return bool(self.backend.verify_password(
self.settings,
distinguished_name,
password,
return bool(self._with_failover(
lambda endpoint_settings: self.backend.verify_password(
endpoint_settings,
distinguished_name,
password,
)
))

def probe(self):
bind_password = _read_secret(self.settings.bind_password_file)
try:
return bool(self.backend.probe(self.settings, bind_password))
return bool(self._with_failover(
lambda endpoint_settings: self.backend.probe(
endpoint_settings,
bind_password,
)
))
finally:
bind_password = None
1 change: 1 addition & 0 deletions app/security_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
'ldap': (
'LDAP_ENABLED',
'LDAP_URL',
'LDAP_BACKUP_URL',
'LDAP_BASE_DN',
'LDAP_BIND_DN',
'LDAP_BIND_PASSWORD_FILE',
Expand Down
29 changes: 20 additions & 9 deletions app/session_insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@
"""


LINUX_NETWORK_AWK_PROGRAM = r"""NR > 2 {
separator = index($0, ":")
if (separator == 0) next
interface = substr($0, 1, separator - 1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", interface)
if (interface == "lo") next
counters = substr($0, separator + 1)
sub(/^[[:space:]]+/, "", counters)
count = split(counters, fields, /[[:space:]]+/)
if (count >= 16) {
received += fields[1]
transmitted += fields[9]
}
} END {
print "network_received_bytes=" received + 0
print "network_transmitted_bytes=" transmitted + 0
}"""


LINUX_DIAGNOSTICS_COMMAND = LINUX_STATS_COMMAND + r"""
if [ -r /proc/meminfo ] && command -v awk >/dev/null 2>&1; then
awk '
Expand All @@ -76,15 +95,7 @@
}' /proc/stat 2>/dev/null
fi
if [ -r /proc/net/dev ] && command -v awk >/dev/null 2>&1; then
awk -F '[: ]+' 'NR > 2 {
if ($2 != "lo") {
received += $3
transmitted += $11
}
} END {
print "network_received_bytes=" received + 0
print "network_transmitted_bytes=" transmitted + 0
}' /proc/net/dev 2>/dev/null
awk '""" + LINUX_NETWORK_AWK_PROGRAM + r"""' /proc/net/dev 2>/dev/null
fi

process_probe=$(ps -p $$ -o pid= 2>&1)
Expand Down
71 changes: 49 additions & 22 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
)
LDAP_PROVIDER_ID = os.environ.get('LDAP_PROVIDER_ID', 'default').strip()
LDAP_URL = os.environ.get('LDAP_URL', '').strip()
LDAP_BACKUP_URL = os.environ.get('LDAP_BACKUP_URL', '').strip()
LDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', '').strip()
LDAP_BIND_DN = os.environ.get('LDAP_BIND_DN', '').strip()
LDAP_BIND_PASSWORD_FILE = os.environ.get(
Expand Down Expand Up @@ -707,28 +708,54 @@ def _canonical_smb_target(raw_value):
'LDAP_ENABLED is true'
)

ldap_url_error = (
'SECURITY ERROR: LDAP_URL must be an exact ldap:// or '
'ldaps:// server URL without credentials, path, query, or '
'fragment and with a valid port'
)
try:
parsed_ldap_url = urlsplit(LDAP_URL)
ldap_hostname = parsed_ldap_url.hostname
ldap_port = parsed_ldap_url.port
except ValueError as exc:
raise RuntimeError(ldap_url_error) from exc
if (
parsed_ldap_url.scheme not in {'ldap', 'ldaps'}
or not ldap_hostname
or (ldap_port is not None and ldap_port < 1)
or parsed_ldap_url.username is not None
or parsed_ldap_url.password is not None
or parsed_ldap_url.path
or parsed_ldap_url.query
or parsed_ldap_url.fragment
):
raise RuntimeError(ldap_url_error)
def _validate_ldap_url(setting_name, value):
ldap_url_error = (
f'SECURITY ERROR: {setting_name} must be an exact ldap:// or '
'ldaps:// server URL without credentials, path, query, or '
'fragment and with a valid port'
)
try:
parsed_ldap_url = urlsplit(value)
ldap_hostname = parsed_ldap_url.hostname
ldap_port = parsed_ldap_url.port
except ValueError as exc:
raise RuntimeError(ldap_url_error) from exc
if (
parsed_ldap_url.scheme not in {'ldap', 'ldaps'}
or not ldap_hostname
or (ldap_port is not None and ldap_port < 1)
or parsed_ldap_url.username is not None
or parsed_ldap_url.password is not None
or parsed_ldap_url.path
or parsed_ldap_url.query
or parsed_ldap_url.fragment
):
raise RuntimeError(ldap_url_error)
return parsed_ldap_url

primary_ldap_url = _validate_ldap_url('LDAP_URL', LDAP_URL)
if LDAP_BACKUP_URL:
backup_ldap_url = _validate_ldap_url(
'LDAP_BACKUP_URL',
LDAP_BACKUP_URL,
)

def _ldap_endpoint_identity(parsed_url):
default_port = 636 if parsed_url.scheme == 'ldaps' else 389
return (
parsed_url.scheme,
parsed_url.hostname.casefold().rstrip('.'),
parsed_url.port or default_port,
)

if (
_ldap_endpoint_identity(backup_ldap_url)
== _ldap_endpoint_identity(primary_ldap_url)
):
raise RuntimeError(
'SECURITY ERROR: LDAP_BACKUP_URL must identify a '
'different server than LDAP_URL'
)
if LDAP_USER_FILTER.count('{username}') != 1:
raise RuntimeError(
'SECURITY ERROR: LDAP_USER_FILTER must contain exactly one '
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.ldap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ services:
LDAP_AUTO_PROVISION: "false"
LDAP_PROVIDER_ID: default
LDAP_URL: ""
# Optional failover endpoint for the same directory and CA trust.
LDAP_BACKUP_URL: ""
LDAP_BASE_DN: ""
LDAP_BIND_DN: ""
LDAP_USER_FILTER: ""
Expand Down
19 changes: 16 additions & 3 deletions docs/ldap-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ the provided `docker-compose.ldap.yml` overlay with the same WebSSH image; no

Collect these values before activation:

1. An LDAP server DNS name whose TLS certificate matches that name.
1. A primary LDAP server DNS name whose TLS certificate matches that name and,
optionally, a backup server with its own certificate-matching DNS name.
2. Whether to use `ldap://host:389` with StartTLS or `ldaps://host:636`.
3. The user search base DN.
4. A read-only bind account DN and its password. It needs only enough access to
Expand All @@ -67,6 +68,7 @@ LDAP_ENABLED: "true"
LDAP_AUTO_PROVISION: "false"
LDAP_PROVIDER_ID: corp-ad
LDAP_URL: ldaps://dc01.ad.example.com:636
LDAP_BACKUP_URL: ldaps://dc02.ad.example.com:636
LDAP_BASE_DN: OU=People,DC=ad,DC=example,DC=com
LDAP_BIND_DN: CN=svc-webssh,OU=Service Accounts,DC=ad,DC=example,DC=com
LDAP_USER_FILTER: "(&(objectCategory=person)(objectClass=user)(sAMAccountName={username})(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
Expand All @@ -77,13 +79,22 @@ The final filter clause excludes disabled AD accounts. If the organization has
a dedicated WebSSH access group, add a directory-approved `memberOf` clause.
Nested group semantics vary and must be validated by the AD administrator.

`LDAP_BACKUP_URL` is optional and must point to the same logical directory as
`LDAP_URL`. WebSSH tries it only when the primary endpoint is unavailable. Each
URL is connected to directly, so the CA bundle must validate the DNS name in
that endpoint's certificate; a round-robin alias is not required. A completed
bind that rejects a user's password is authoritative and is not retried against
the other endpoint. Keep the provider ID, base DN, bind account, filter, and
stable-ID attribute identical across both endpoints.

### Generic OpenLDAP example

```yaml
LDAP_ENABLED: "true"
LDAP_AUTO_PROVISION: "false"
LDAP_PROVIDER_ID: primary-openldap
LDAP_URL: ldap://ldap.example.com:389
# LDAP_BACKUP_URL: ldap://ldap-backup.example.com:389
LDAP_BASE_DN: ou=people,dc=example,dc=com
LDAP_BIND_DN: cn=svc-webssh,ou=services,dc=example,dc=com
LDAP_USER_FILTER: "(&(objectClass=inetOrgPerson)(uid={username})(!(pwdAccountLockedTime=*)))"
Expand Down Expand Up @@ -204,8 +215,10 @@ remain in the separate secret volume and are intentionally not included.
administrator. WebSSH never chooses one result from an ambiguous search.
- **AD user is found but cannot bind:** check disabled/locked/expired state,
logon restrictions, and whether the DC accepts the supplied username flow.
- **LDAP outage signs users out:** this is intentional fail-closed behavior.
Restore directory/TLS service; do not enable a password fallback.
- **LDAP outage signs users out:** WebSSH tries the optional backup endpoint
after a transport failure. If neither endpoint is available, the intentional
fail-closed behavior applies. Restore directory/TLS service; do not enable a
password fallback.
- **Provider ID changed:** restore the original `LDAP_PROVIDER_ID`. It is part
of every stable mapping and should remain constant for that directory.

Expand Down
9 changes: 9 additions & 0 deletions docs/wiki/LDAP-and-Active-Directory.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ services:
LDAP_ENABLED: "true"
LDAP_PROVIDER_ID: corp-ad
LDAP_URL: ldaps://dc01.ad.example.com:636
LDAP_BACKUP_URL: ldaps://dc02.ad.example.com:636
LDAP_BASE_DN: OU=People,DC=ad,DC=example,DC=com
LDAP_BIND_DN: CN=svc-webssh,OU=Service Accounts,DC=ad,DC=example,DC=com
LDAP_USER_FILTER: "(&(objectCategory=person)(objectClass=user)(sAMAccountName={username})(!(userAccountControl:1.2.840.113556.1.4.803:=2)))"
Expand All @@ -71,6 +72,12 @@ The final filter clause excludes disabled AD accounts. If access is restricted
to a group, use a directory-approved `memberOf` rule. Nested group semantics
vary and must be validated by the AD administrator.

`LDAP_BACKUP_URL` is optional and must identify another server for the same
logical directory. It is tried only after the primary endpoint is unavailable.
WebSSH connects to each URL directly, so every endpoint DNS name must match its
own TLS certificate and be trusted by `LDAP_CA_FILE`; a round-robin alias is not
needed. Invalid user credentials are not retried against the backup endpoint.

### OpenLDAP example

```yaml
Expand All @@ -80,6 +87,7 @@ services:
LDAP_ENABLED: "true"
LDAP_PROVIDER_ID: primary-openldap
LDAP_URL: ldap://ldap.example.com:389
# LDAP_BACKUP_URL: ldap://ldap-backup.example.com:389
LDAP_BASE_DN: ou=people,dc=example,dc=com
LDAP_BIND_DN: cn=svc-webssh,ou=services,dc=example,dc=com
LDAP_USER_FILTER: "(&(objectClass=inetOrgPerson)(uid={username})(!(pwdAccountLockedTime=*)))"
Expand All @@ -96,6 +104,7 @@ attribute is not available.
| `LDAP_ENABLED` | `false` | Enable the subsystem |
| `LDAP_PROVIDER_ID` | `default` | Stable 1-64 character local provider identifier |
| `LDAP_URL` | empty | Exact `ldap://` or `ldaps://` server URL |
| `LDAP_BACKUP_URL` | empty | Optional second URL for the same directory, tried after transport failure |
| `LDAP_BASE_DN` | empty | User subtree base |
| `LDAP_BIND_DN` | empty | Least-privilege search account DN |
| `LDAP_BIND_PASSWORD_FILE` | `/run/webssh-auth/ldap_bind_password` | Absolute private file path |
Expand Down
2 changes: 1 addition & 1 deletion static/css/session-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@

.fm-embedded-mode .fm-toolbar-right {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 4px;
}

Expand Down
9 changes: 9 additions & 0 deletions static/css/sftp-file-manager.css
Original file line number Diff line number Diff line change
Expand Up @@ -2604,6 +2604,11 @@
background: var(--bg-primary);
}

.fm-source-search:focus-within {
outline: 2px solid var(--ws2-focus, var(--accent-primary));
outline-offset: 2px;
}

.fm-source-search .material-icons {
color: var(--text-muted);
font-size: 19px;
Expand All @@ -2618,6 +2623,10 @@
color: var(--text-primary);
}

.fm-source-search input:focus-visible {
outline: 0 !important;
}

.fm-source-groups {
display: flex;
min-height: 0;
Expand Down
Loading
Loading