mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-21 06:29:24 +08:00
Let users sign in with their directory (Active Directory / OpenLDAP) credentials through the existing login form. Reuses sso_providers (kind='ldap') and user_sso_identities, so no schema change is needed. Authentication is search-then-bind: a service account resolves the user and only the returned DN is bound with the submitted password, so user input is never used as a bind identity. Roles come from a role template at first provisioning only; changing a directory group later never re-templates an existing account. Directory outages and wrong passwords stay distinguishable (502 LDAP_UNAVAILABLE vs 401 AUTH_FAILED). Hardening from review: - An account holding its own password stops at the local check, so a local secret is never forwarded to the directory nor counted against that directory's own lockout policy. - A lookup without a unique exact identifier match is refused instead of being bound against the first hit. - A failure during the user bind surfaces as LDAP_UNAVAILABLE rather than a 401 credential verdict. - Binds are throttled before they reach the directory, keyed per identifier and client address, with the address resolved from a trusted peer so a spoofed X-Forwarded-For cannot reset the budget. - Enabling a plain ldap:// URL without StartTLS is rejected; a disabled certificate check is surfaced as a warning. - auto_provision defaults to off, with an optional allowed_groups allow-list and a group-search mode (member / uniqueMember / memberUid) for directories that do not expose memberOf. Nested groups are not resolved. - The identity key is configurable (entryUUID / objectGUID) and blank by default; a connectivity probe reports which attribute the directory exposes. - Config changes are audited against the acting admin, and referrals are no longer followed during binds. Tests cover the above without a live directory (ldap3.Connection is the only thing replaced); a live test exercises a real directory when configured. Co-authored-by: jubaoliang <jubaoliang@gmail.com>
72 lines
2.5 KiB
Python
72 lines
2.5 KiB
Python
"""Client-address resolution for rate limiting.
|
|
|
|
The header is caller-supplied, so trusting it unconditionally would let anyone
|
|
reset a throttle by varying it. These cases pin the trust rules.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from octop.api.common.client_ip import UNKNOWN_CLIENT, resolve_client_ip
|
|
|
|
|
|
@dataclass
|
|
class _Client:
|
|
host: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class _Request:
|
|
"""Minimal ASGI request surface used by the resolver."""
|
|
|
|
peer: str | None = None
|
|
forwarded_for: str | None = None
|
|
client: Any = field(init=False)
|
|
headers: dict[str, str] = field(init=False, default_factory=dict)
|
|
|
|
def __post_init__(self) -> None:
|
|
self.client = _Client(self.peer) if self.peer is not None else None
|
|
if self.forwarded_for is not None:
|
|
self.headers = {"x-forwarded-for": self.forwarded_for}
|
|
|
|
|
|
def test_direct_peer_is_used_and_the_header_is_ignored():
|
|
"""A client talking straight to us cannot name its own address."""
|
|
request = _Request(peer="203.0.113.7", forwarded_for="10.0.0.1")
|
|
assert resolve_client_ip(request) == "203.0.113.7"
|
|
|
|
|
|
def test_loopback_peer_may_forward_the_client_address():
|
|
request = _Request(peer="127.0.0.1", forwarded_for="203.0.113.7")
|
|
assert resolve_client_ip(request) == "203.0.113.7"
|
|
|
|
|
|
def test_forwarded_chain_takes_the_right_most_hop():
|
|
"""A trusted proxy appends what it saw; values to its left may be forged."""
|
|
request = _Request(peer="::1", forwarded_for="1.2.3.4, 203.0.113.7")
|
|
assert resolve_client_ip(request) == "203.0.113.7"
|
|
|
|
|
|
def test_a_client_supplied_prefix_cannot_choose_its_own_bucket():
|
|
"""The attacker controls the left of the chain, not the appended hop."""
|
|
honest = _Request(peer="127.0.0.1", forwarded_for="203.0.113.7")
|
|
forged = _Request(peer="127.0.0.1", forwarded_for="9.9.9.9, 203.0.113.7")
|
|
assert resolve_client_ip(forged) == resolve_client_ip(honest)
|
|
|
|
|
|
def test_loopback_peer_without_the_header_falls_back_to_the_peer():
|
|
assert resolve_client_ip(_Request(peer="127.0.0.1")) == "127.0.0.1"
|
|
|
|
|
|
def test_missing_peer_collapses_to_a_single_bucket():
|
|
"""Better one shared bucket than a fresh one per request."""
|
|
assert resolve_client_ip(_Request()) == UNKNOWN_CLIENT
|
|
assert resolve_client_ip(_Request(peer="")) == UNKNOWN_CLIENT
|
|
|
|
|
|
def test_blank_header_entries_are_ignored():
|
|
request = _Request(peer="127.0.0.1", forwarded_for=" , ")
|
|
assert resolve_client_ip(request) == "127.0.0.1"
|