Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@Kumpelinus
Copy link
Contributor

@Kumpelinus Kumpelinus commented Sep 13, 2025

Add support for trusted header authentication that allows users to be authenticated via HTTP headers (e.g., from reverse proxies like Authelia, Authentik, or Traefik ForwardAuth).

This implementation provides a tokenless authentication flow that works alongside the existing JWT-based authentication:

  • Mutually exclusive authentication: When a trusted header is present, it takes precedence over JWT authentication in GraphQL requests
  • IP allowlist security: Only requests from configured trusted proxy IP addresses/networks are allowed to use header authentication
  • Configurable options: Header name, logout URL, and trusted proxy networks are all configurable

Configuration options:

  • trusted_header_options.enabled - Enable/disable the feature
  • trusted_header_options.header_name - Header containing username (default: "Remote-User")
  • trusted_header_options.logout_url - Optional logout redirect URL
  • trusted_header_options.trusted_proxies - IP addresses/CIDR networks allowed to send trusted headers (default: localhost only)

The existing username/password login remains available as fallback when trusted headers are not present.

@coderabbitai
Copy link

coderabbitai bot commented Sep 13, 2025

📝 Walkthrough

Walkthrough

Adds trusted‑header authentication across server and client: new auth response enum allowing token or trusted‑header payloads, server-side trusted‑header flow with IP/CIDR checks and header validation, CLI/config surfaces and template for trusted header options, AppState and TCP wiring to carry the options, GraphQL route branching to prefer trusted‑header when present, and client refresh logic unified to accept the new ServerAuthResponse.

Changes

Cohort / File(s) Summary
Client auth refresh unification
app/src/infra/api.rs
Adds extract_user_info_from_auth_response to handle token and trusted‑header responses; updates HostService::refresh to consume unified login::ServerAuthResponse.
Auth types (public)
crates/auth/src/lib.rs
Adds login::ServerTrustedHeaderResponse and untagged enum login::ServerAuthResponse { Token, TrustedHeader } for dual response shapes.
Trusted-header config surfaces
server/src/configuration.rs, server/src/cli.rs, lldap_config.docker_template.toml
Introduces TrustedHeaderOptions (enabled, header_name, logout_url, trusted_proxies) and CLI TrustedHeaderOpts with env overrides; adds template config block for trusted header options.
HTTP/TCP state propagation
server/src/tcp_server.rs
AppState gains trusted_header_options; http_config signature extended; server wiring passes configuration through into AppState.
Auth service flow
server/src/auth_service.rs
Refresh now tries token first, then trusted‑header when enabled. Adds IP/CIDR parsing (ipnet), client IP extraction, trusted‑proxy checks, header validation, user/group lookup, and returns ServerAuthResponse::Token or ::TrustedHeader. Exposes pub(crate) check_if_trusted_header_is_valid.
GraphQL auth flow
server/src/graphql_server.rs
Route branches: if trusted header enabled and present, uses check_if_trusted_header_is_valid; otherwise falls back to JWT validation. Context population unchanged.
Dependency
server/Cargo.toml
Adds ipnet = "2.5" for CIDR handling.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor C as Client
  participant S as HTTP Server (auth_service)
  participant CFG as TrustedHeaderOptions
  participant B as LLDAP Backend

  Note over S,CFG: Refresh flow with fallback

  C->>S: GET /auth/refresh (cookies/headers)
  S->>S: try_refresh_token()
  alt Token refresh succeeds
    S-->>C: JSON: ServerAuthResponse::Token{ token, refresh_token: null }
  else Token refresh fails
    S->>CFG: Check enabled + header_name
    alt Trusted header enabled
      S->>S: validate_trusted_header(request, CFG)
      S->>B: lookup user + groups
      alt User valid
        S-->>C: JSON: ServerAuthResponse::TrustedHeader{ userId, isAdmin }
      else Invalid
        S-->>C: 401 AuthenticationError
      end
    else Not enabled
      S-->>C: 401 AuthenticationError
    end
  end
Loading
sequenceDiagram
  autonumber
  actor U as User Agent
  participant G as GraphQL Route
  participant CFG as TrustedHeaderOptions
  participant JWT as JWT Validator
  participant TH as Trusted Header Validator

  Note over G: GraphQL authentication selection

  U->>G: HTTP request (headers)
  G->>CFG: Is trusted header enabled?
  alt Enabled and header present
    G->>TH: check_if_trusted_header_is_valid()
    alt OK
      G-->>U: Execute request with trusted-header context
    else Error
      G-->>U: 401/forbidden per existing handling
    end
  else Fallback to JWT
    G->>JWT: Validate Bearer token
    alt OK
      G-->>U: Execute request with JWT context
    else Error
      G-->>U: 401/forbidden per existing handling
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

I tap my paw on the proxy log,
Sniff headers, hop through CIDR fog.
If tokens fail, I’m still on track—
A trusted nibble brings auth back.
New fields sprout like garden greens,
Config burrows through the seams.
Thump-thump: secure by rabbit means. 🐇🛡️

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.81% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "auth: Add trusted header authentication with IP allowlist" succinctly and accurately describes the primary change in the PR — adding trusted-header authentication protected by an IP/CIDR allowlist — and matches the PR objectives and modified files; it is concise, specific, and free of noisy details.

📜 Recent review details

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 464cbf1 and 662dfab.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • app/src/infra/api.rs (2 hunks)
  • crates/auth/src/lib.rs (1 hunks)
  • lldap_config.docker_template.toml (1 hunks)
  • server/Cargo.toml (1 hunks)
  • server/src/auth_service.rs (6 hunks)
  • server/src/cli.rs (2 hunks)
  • server/src/configuration.rs (5 hunks)
  • server/src/graphql_server.rs (3 hunks)
  • server/src/tcp_server.rs (8 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • server/Cargo.toml
  • crates/auth/src/lib.rs
  • lldap_config.docker_template.toml
🧰 Additional context used
🧬 Code graph analysis (2)
server/src/graphql_server.rs (1)
server/src/auth_service.rs (2)
  • check_if_token_is_valid (775-802)
  • check_if_trusted_header_is_valid (805-852)
server/src/auth_service.rs (1)
crates/auth/src/lib.rs (2)
  • new (138-140)
  • new (190-192)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: build-bin (aarch64-unknown-linux-musl)
  • GitHub Check: build-bin (armv7-unknown-linux-musleabihf)
  • GitHub Check: build-bin (x86_64-unknown-linux-musl)
  • GitHub Check: build-ui
  • GitHub Check: cargo test
🔇 Additional comments (5)
server/src/auth_service.rs (5)

18-18: LGTM! Required imports for IP/CIDR handling.

These imports support the IP allowlist functionality.

Also applies to: 34-34, 36-36


104-131: Approve fallback logic, but fix critical security issues first.

The try-token-then-header fallback is correct. However, the trusted header path has critical XFF spoofing vulnerabilities flagged in other comments that must be resolved.


133-172: Response format change: verify client compatibility.

The token response is now wrapped in ServerAuthResponse::Token (lines 166-171). Ensure all clients handle the new enum.


269-294: Approve logic after security fixes.

The trusted header auth flow is correct, contingent on fixing the IP validation issues flagged elsewhere.


512-532: LGTM! Trusted header response helper.

Returns the correct ServerTrustedHeaderResponse payload.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Kumpelinus
Copy link
Contributor Author

Fixes #352

@codecov
Copy link

codecov bot commented Sep 13, 2025

Codecov Report

❌ Patch coverage is 17.11230% with 155 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
server/src/auth_service.rs 0.00% 131 Missing ⚠️
server/src/graphql_server.rs 52.00% 12 Missing ⚠️
server/src/configuration.rs 56.00% 11 Missing ⚠️
crates/auth/src/lib.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1285      +/-   ##
==========================================
- Coverage   86.06%   84.85%   -1.22%     
==========================================
  Files          71       71              
  Lines       12201    12411     +210     
==========================================
+ Hits        10501    10531      +30     
- Misses       1700     1880     +180     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (7)
lldap_config.docker_template.toml (1)

162-181: Add env-var hints for trusted_header_options; no duplicate section found

  • Add env-var hints for: LLDAP_TRUSTED_HEADER_OPTIONS__HEADER_NAME, LLDAP_TRUSTED_HEADER_OPTIONS__LOGOUT_URL, LLDAP_TRUSTED_HEADER_OPTIONS__TRUSTED_PROXIES — insert as commented lines immediately above the [trusted_header_options] block.
  • Duplication check: single occurrence at lldap_config.docker_template.toml:166.
app/src/infra/api.rs (1)

184-191: Fix error message: this is refresh, not start.

-            "Could not start authentication: ",
+            "Could not refresh authentication: ",
server/src/tcp_server.rs (1)

3-3: Wiring looks correct; consider bundling HTTP config args later.

  • Import and propagation of TrustedHeaderOptions into AppState and http_config are sound. LGTM.
  • Long http_config signature is now a bit unwieldy; consider a small HttpRuntimeContext struct in a follow-up to drop the too_many_arguments allow.

Also applies to: 115-125, 124-137, 181-182, 221-222, 241-242, 258-259

crates/auth/src/lib.rs (1)

63-76: Untagged enum is fine now; consider tagging for future-proofing.

#[serde(untagged)] works because the shapes don't overlap. If future variants add optional fields, deserialization ambiguity can bite. Consider a tagged enum (e.g., type: "token" | "trustedHeader") for long-term stability.

server/src/configuration.rs (1)

86-104: Sane defaults; add early validation of proxies at config load (optional).

Consider storing parsed proxies (IpNet/IpAddr) to fail-fast on misconfig and avoid per-request parsing.

server/src/auth_service.rs (2)

267-292: Minor duplication with get_trusted_header_successful_response.

You recompute is_admin here and in the helper. Consider delegating to the helper to keep one source of truth.

-    // Get user groups to determine admin status
-    let groups = data
-        .get_readonly_handler()
-        .get_user_groups(&user_id)
-        .await?;
-    let is_admin = groups
-        .iter()
-        .any(|g| g.display_name == "lldap_admin".into());
-
-    Ok(
-        HttpResponse::Ok().json(login::ServerAuthResponse::TrustedHeader(
-            login::ServerTrustedHeaderResponse {
-                user_id: user_id.to_string(),
-                is_admin,
-            },
-        )),
-    )
+    // Reuse the helper for response shaping
+    get_trusted_header_successful_response::<Backend>(data, &user_id).await

509-530: Return the unified enum for consistency.

Elsewhere you respond with ServerAuthResponse::TrustedHeader. Consider doing the same here to keep payload shapes consistent.

-    Ok(
-        HttpResponse::Ok().json(&login::ServerTrustedHeaderResponse {
-            user_id: user_id.to_string(),
-            is_admin,
-        }),
-    )
+    Ok(HttpResponse::Ok().json(
+        login::ServerAuthResponse::TrustedHeader(login::ServerTrustedHeaderResponse {
+            user_id: user_id.to_string(),
+            is_admin,
+        }),
+    ))
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c68f9e7 and 464cbf1.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • app/src/infra/api.rs (2 hunks)
  • crates/auth/src/lib.rs (1 hunks)
  • lldap_config.docker_template.toml (1 hunks)
  • server/Cargo.toml (1 hunks)
  • server/src/auth_service.rs (6 hunks)
  • server/src/cli.rs (2 hunks)
  • server/src/configuration.rs (5 hunks)
  • server/src/graphql_server.rs (3 hunks)
  • server/src/tcp_server.rs (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
server/src/graphql_server.rs (1)
server/src/auth_service.rs (2)
  • check_if_token_is_valid (772-799)
  • check_if_trusted_header_is_valid (802-849)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: build-bin (aarch64-unknown-linux-musl)
  • GitHub Check: build-bin (x86_64-unknown-linux-musl)
  • GitHub Check: build-bin (armv7-unknown-linux-musleabihf)
  • GitHub Check: build-ui
  • GitHub Check: cargo test
  • GitHub Check: cargo clippy
🔇 Additional comments (6)
server/Cargo.toml (1)

32-32: No change required — don't enable ipnet/serde.
trusted_proxies is Vec in server/src/configuration.rs and server/src/auth_service.rs parses them with IpNet::from_str, so keeping ipnet = "2.5" (no serde feature) is correct.

server/src/graphql_server.rs (2)

1-4: Imports and logging additions look good.

Also applies to: 16-16, 20-20


127-137: JWT extractor helper is tidy and correct.

server/src/cli.rs (1)

178-180: Wiring the new CLI block into RunOpts looks good.

server/src/configuration.rs (1)

154-156: Configuration surface addition LGTM.

server/src/auth_service.rs (1)

106-129: Refresh fallback shape change — confirm clients accept login::ServerAuthResponse (Token | TrustedHeader).

Some consumers still deserialize ServerLoginResponse directly; if they call /auth/refresh they will fail. Affected call sites (grep results):

  • set-password/src/main.rs:61
  • migration-tool/src/lldap.rs:169
  • server/tests/common/auth.rs:22-24
  • app/src/infra/api.rs:80-96 (extract_user_info_from_auth_response already handles both)
    Server responses: server/src/auth_service.rs:164-167 (Token) and 285-287 (TrustedHeader).

Either ensure the listed callers do not call /auth/refresh, or update them to parse login::ServerAuthResponse and handle the TrustedHeader variant.

Comment on lines +89 to +99
fn extract_user_info_from_auth_response(
response: login::ServerAuthResponse,
) -> Result<(String, bool)> {
match response {
login::ServerAuthResponse::Token(token_response) => set_cookies_from_jwt(token_response),
login::ServerAuthResponse::TrustedHeader(header_response) => {
Ok((header_response.user_id, header_response.is_admin))
}
}
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Unify cookie expectations across auth paths or verify callers.

Trusted-header path returns (user_id, is_admin) without setting cookies, unlike the token path. Verify the UI does not rely on user_id/is_admin cookies post-refresh; otherwise, set equivalent cookies or migrate callers.

Run to find cookie reads:


🏁 Script executed:

#!/bin/bash
rg -nP '\b(get_cookie|cookies?)\b|["'\''](user_id|is_admin)["'\'']' -g '!**/target/**' -C2

Length of output: 148


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "PWD: $(pwd)"
echo "Listing top-level files:"
ls -la | sed -n '1,200p' || true
echo "---- rg search (auth/cookie related symbols) ----"
rg --hidden -n -S -C3 -g '**/*' -g '!**/target/**' -g '!**/node_modules/**' -g '!.git/**' 'extract_user_info_from_auth_response|set_cookies_from_jwt|ServerAuthResponse|TrustedHeader|Token|document\.cookie|Cookies\.get|Cookies\.set|getCookie|get_cookie|user_id|is_admin|Set-Cookie|set_cookie|cookieStore|cookie\b' || true

echo "---- rg search (function defs / direct calls) ----"
rg --hidden -n -S -C3 -g '**/*' -g '!**/target/**' -g '!**/node_modules/**' -g '!.git/**' 'fn extract_user_info_from_auth_response|fn set_cookies_from_jwt|set_cookies_from_jwt\(|extract_user_info_from_auth_response\(' || true

echo "---- show app/src/infra/api.rs (if present) ----"
if [ -f app/src/infra/api.rs ]; then
  sed -n '1,240p' app/src/infra/api.rs || true
else
  echo "app/src/infra/api.rs not found"
fi

echo "---- done ----"

Length of output: 221312


Unify cookie expectations across auth paths or verify callers.

Token path sets cookies via set_cookies_from_jwt; TrustedHeader returns (user_id, is_admin) but does not set cookies — the frontend boot reads cookies (app/src/components/app.rs: ~63-71) and HostService::refresh uses extract_user_info_from_auth_response (app/src/infra/api.rs: ~80-96), so TrustedHeader responses will not persist across reloads.

  • Fix options (pick one): set the same cookies for TrustedHeader responses (use set_cookie / mirror set_cookies_from_jwt), or stop relying on cookies at startup and persist auth purely from refresh()’s return value. (See app/src/infra/api.rs: set_cookies_from_jwt/extract_user_info_from_auth_response.)
  • Also ensure logout clears both cookies (app/src/components/logout.rs: ~33-37 currently deletes only "user_id").
🤖 Prompt for AI Agents
In app/src/infra/api.rs around lines 89-99, TrustedHeader path returns (user_id,
is_admin) but does not persist cookies like the Token path, causing frontend
startup to lose auth on reload; modify the TrustedHeader branch to set the same
cookies as the Token branch (reuse or mirror set_cookies_from_jwt behavior: set
"user_id" and the auth cookie/jwt-equivalent and any expiry/secure flags) and
then return Ok((user_id, is_admin)) so both auth flows have identical cookie
state; additionally update logout handling (app/src/components/logout.rs around
lines ~33-37) to clear both cookies ("user_id" and the auth token cookie) to
ensure a full sign-out.

Comment on lines +172 to +203
fn get_client_ip(request: &HttpRequest) -> Option<IpAddr> {
// Try to get the real IP from common proxy headers first
if let Some(forwarded_for) = request.headers().get("x-forwarded-for") {
if let Ok(header_value) = forwarded_for.to_str() {
// Take the first IP in the chain (the original client)
if let Some(ip_str) = header_value.split(',').next() {
if let Ok(ip) = IpAddr::from_str(ip_str.trim()) {
return Some(ip);
}
}
}
}

// Try X-Real-IP header
if let Some(real_ip) = request.headers().get("x-real-ip") {
if let Ok(header_value) = real_ip.to_str() {
if let Ok(ip) = IpAddr::from_str(header_value.trim()) {
return Some(ip);
}
}
}

// Fallback to connection info
if let Some(peer_addr) = request.peer_addr() {
return Some(peer_addr.ip());
}

None
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not trust X-Forwarded-For for allowlist checks. Use the peer IP.

Using XFF/X-Real-IP to compute client_ip and then checking it against the proxy allowlist enables header spoofing and breaks legit deployments. Only the immediate upstream (peer) must be allowlisted; XFF may be parsed only after that.

Apply this minimal fix if you keep get_client_ip for logging only:

-fn get_client_ip(request: &HttpRequest) -> Option<IpAddr> {
+fn get_client_ip(request: &HttpRequest) -> Option<IpAddr> {
     // Try to get the real IP from common proxy headers first
@@
-    // Fallback to connection info
-    if let Some(peer_addr) = request.peer_addr() {
-        return Some(peer_addr.ip());
-    }
+    // Fallback to connection info (peer IP)
+    if let Some(peer_addr) = request.peer_addr() {
+        return Some(peer_addr.ip());
+    }
@@
-    None
+    None
 }

And fix the actual checks in validators below (see next comments).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn get_client_ip(request: &HttpRequest) -> Option<IpAddr> {
// Try to get the real IP from common proxy headers first
if let Some(forwarded_for) = request.headers().get("x-forwarded-for") {
if let Ok(header_value) = forwarded_for.to_str() {
// Take the first IP in the chain (the original client)
if let Some(ip_str) = header_value.split(',').next() {
if let Ok(ip) = IpAddr::from_str(ip_str.trim()) {
return Some(ip);
}
}
}
}
// Try X-Real-IP header
if let Some(real_ip) = request.headers().get("x-real-ip") {
if let Ok(header_value) = real_ip.to_str() {
if let Ok(ip) = IpAddr::from_str(header_value.trim()) {
return Some(ip);
}
}
}
// Fallback to connection info
if let Some(peer_addr) = request.peer_addr() {
return Some(peer_addr.ip());
}
None
}
fn get_client_ip(request: &HttpRequest) -> Option<IpAddr> {
// Try to get the real IP from common proxy headers first
if let Some(forwarded_for) = request.headers().get("x-forwarded-for") {
if let Ok(header_value) = forwarded_for.to_str() {
// Take the first IP in the chain (the original client)
if let Some(ip_str) = header_value.split(',').next() {
if let Ok(ip) = IpAddr::from_str(ip_str.trim()) {
return Some(ip);
}
}
}
}
// Try X-Real-IP header
if let Some(real_ip) = request.headers().get("x-real-ip") {
if let Ok(header_value) = real_ip.to_str() {
if let Ok(ip) = IpAddr::from_str(header_value.trim()) {
return Some(ip);
}
}
}
// Fallback to connection info (peer IP)
if let Some(peer_addr) = request.peer_addr() {
return Some(peer_addr.ip());
}
None
}
🤖 Prompt for AI Agents
In server/src/auth_service.rs around lines 172 to 201, the current get_client_ip
trusts X-Forwarded-For/X-Real-IP which enables header spoofing for allowlist
checks; change it so it returns only the immediate peer IP
(request.peer_addr().map(|p| p.ip())) and drop using proxy headers here; if you
still want header values for logging, expose a separate function or return a
tuple/struct containing peer IP plus optional parsed XFF/X-Real-IP for logging
only, but ensure all allowlist/validator code uses only the peer IP.

Comment on lines +202 to +214
fn is_ip_in_trusted_proxies(ip: IpAddr, trusted_proxies: &[String]) -> bool {
for proxy_str in trusted_proxies {
if let Ok(cidr) = IpNet::from_str(proxy_str) {
if cidr.contains(&ip) {
return true;
}
}
}
false
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Support single-IP entries; the CLI help promises it.

IpNet::from_str("1.2.3.4") fails. Add a single-IP fallback to honor docs.

 fn is_ip_in_trusted_proxies(ip: IpAddr, trusted_proxies: &[String]) -> bool {
     for proxy_str in trusted_proxies {
-        if let Ok(cidr) = IpNet::from_str(proxy_str) {
-            if cidr.contains(&ip) {
-                return true;
-            }
-        }
+        if let Ok(cidr) = IpNet::from_str(proxy_str) {
+            if cidr.contains(&ip) {
+                return true;
+            }
+            continue;
+        }
+        // Fallback: exact IP match
+        if let Ok(single) = IpAddr::from_str(proxy_str) {
+            if single == ip {
+                return true;
+            }
+        }
     }
     false
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn is_ip_in_trusted_proxies(ip: IpAddr, trusted_proxies: &[String]) -> bool {
for proxy_str in trusted_proxies {
if let Ok(cidr) = IpNet::from_str(proxy_str) {
if cidr.contains(&ip) {
return true;
}
}
}
false
}
fn is_ip_in_trusted_proxies(ip: IpAddr, trusted_proxies: &[String]) -> bool {
for proxy_str in trusted_proxies {
if let Ok(cidr) = IpNet::from_str(proxy_str) {
if cidr.contains(&ip) {
return true;
}
continue;
}
// Fallback: exact IP match
if let Ok(single) = IpAddr::from_str(proxy_str) {
if single == ip {
return true;
}
}
}
false
}
🤖 Prompt for AI Agents
In server/src/auth_service.rs around lines 202 to 212, the current
is_ip_in_trusted_proxies only tries IpNet::from_str and therefore rejects
single-IP entries (e.g. "1.2.3.4"); update the function to fall back to parsing
the proxy_str as an IpAddr when IpNet parsing fails and treat a parsed IpAddr as
a match when it equals the input ip (handle both IPv4 and IPv6), so single-IP
entries listed in trusted_proxies are honored.

Comment on lines +213 to +268
async fn validate_trusted_header<Backend>(
data: &web::Data<AppState<Backend>>,
request: &HttpRequest,
) -> TcpResult<UserId>
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
// Validate client IP is in trusted CIDRs
let client_ip = get_client_ip(request)
.ok_or_else(|| TcpError::UnauthorizedError("Could not determine client IP".to_string()))?;

if !is_ip_in_trusted_proxies(client_ip, &data.trusted_header_options.trusted_proxies) {
return Err(TcpError::UnauthorizedError(format!(
"Client IP {} not in trusted proxies",
client_ip
)));
}

// Get the username from the trusted header
let header_name = &data.trusted_header_options.header_name;
let username = request
.headers()
.get(header_name)
.and_then(|h| h.to_str().ok())
.ok_or_else(|| {
TcpError::UnauthorizedError(format!("Missing trusted header: {}", header_name))
})?;

// Validate the username is not empty
if username.trim().is_empty() {
return Err(TcpError::UnauthorizedError(
"Empty username in trusted header".to_string(),
));
}

let user_id = UserId::new(username);

// Check if the user exists in LLDAP
let user_exists = !data
.get_readonly_handler()
.list_users(Some(UserRequestFilter::UserId(user_id.clone())), false)
.await?
.is_empty();

if !user_exists {
return Err(TcpError::UnauthorizedError(format!(
"User {} not found in LLDAP",
username
)));
}

Ok(user_id)
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix allowlist check: validate the peer IP, not the (spoofable) XFF value.

Current code obtains client_ip via headers and checks that against the allowlist. An attacker can set X-Forwarded-For to an allowed proxy IP and bypass the check. Validate request.peer_addr().ip() instead; only then read the trusted header.

 async fn validate_trusted_header<Backend>(
@@
-    // Validate client IP is in trusted CIDRs
-    let client_ip = get_client_ip(request)
-        .ok_or_else(|| TcpError::UnauthorizedError("Could not determine client IP".to_string()))?;
-
-    if !is_ip_in_trusted_proxies(client_ip, &data.trusted_header_options.trusted_proxies) {
+    // Validate the immediate upstream (peer) IP is a trusted proxy
+    let peer_ip = request
+        .peer_addr()
+        .map(|s| s.ip())
+        .ok_or_else(|| TcpError::UnauthorizedError("Could not determine peer IP".to_string()))?;
+    if !is_ip_in_trusted_proxies(peer_ip, &data.trusted_header_options.trusted_proxies) {
         return Err(TcpError::UnauthorizedError(format!(
-            "Client IP {} not in trusted proxies",
-            client_ip
+            "Peer IP {} not in trusted proxies",
+            peer_ip
         )));
     }
@@
-    let username = request
+    let username = request
         .headers()
         .get(header_name)
         .and_then(|h| h.to_str().ok())
         .ok_or_else(|| {
             TcpError::UnauthorizedError(format!("Missing trusted header: {}", header_name))
         })?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async fn validate_trusted_header<Backend>(
data: &web::Data<AppState<Backend>>,
request: &HttpRequest,
) -> TcpResult<UserId>
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
// Validate client IP is in trusted CIDRs
let client_ip = get_client_ip(request)
.ok_or_else(|| TcpError::UnauthorizedError("Could not determine client IP".to_string()))?;
if !is_ip_in_trusted_proxies(client_ip, &data.trusted_header_options.trusted_proxies) {
return Err(TcpError::UnauthorizedError(format!(
"Client IP {} not in trusted proxies",
client_ip
)));
}
// Get the username from the trusted header
let header_name = &data.trusted_header_options.header_name;
let username = request
.headers()
.get(header_name)
.and_then(|h| h.to_str().ok())
.ok_or_else(|| {
TcpError::UnauthorizedError(format!("Missing trusted header: {}", header_name))
})?;
// Validate the username is not empty
if username.trim().is_empty() {
return Err(TcpError::UnauthorizedError(
"Empty username in trusted header".to_string(),
));
}
let user_id = UserId::new(username);
// Check if the user exists in LLDAP
let user_exists = !data
.get_readonly_handler()
.list_users(Some(UserRequestFilter::UserId(user_id.clone())), false)
.await?
.is_empty();
if !user_exists {
return Err(TcpError::UnauthorizedError(format!(
"User {} not found in LLDAP",
username
)));
}
Ok(user_id)
}
async fn validate_trusted_header<Backend>(
data: &web::Data<AppState<Backend>>,
request: &HttpRequest,
) -> TcpResult<UserId>
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
// Validate the immediate upstream (peer) IP is a trusted proxy
let peer_ip = request
.peer_addr()
.map(|s| s.ip())
.ok_or_else(|| TcpError::UnauthorizedError("Could not determine peer IP".to_string()))?;
if !is_ip_in_trusted_proxies(peer_ip, &data.trusted_header_options.trusted_proxies) {
return Err(TcpError::UnauthorizedError(format!(
"Peer IP {} not in trusted proxies",
peer_ip
)));
}
// Get the username from the trusted header
let header_name = &data.trusted_header_options.header_name;
let username = request
.headers()
.get(header_name)
.and_then(|h| h.to_str().ok())
.ok_or_else(|| {
TcpError::UnauthorizedError(format!("Missing trusted header: {}", header_name))
})?;
// Validate the username is not empty
if username.trim().is_empty() {
return Err(TcpError::UnauthorizedError(
"Empty username in trusted header".to_string(),
));
}
let user_id = UserId::new(username);
// Check if the user exists in LLDAP
let user_exists = !data
.get_readonly_handler()
.list_users(Some(UserRequestFilter::UserId(user_id.clone())), false)
.await?
.is_empty();
if !user_exists {
return Err(TcpError::UnauthorizedError(format!(
"User {} not found in LLDAP",
username
)));
}
Ok(user_id)
}
🤖 Prompt for AI Agents
In server/src/auth_service.rs around lines 213 to 266, the allowlist currently
checks a client IP derived from headers (which is spoofable); change it to
validate the TCP peer IP instead: call request.peer_addr() and extract the IP,
return Unauthorized if peer_addr is None, then check is_ip_in_trusted_proxies
against that peer IP; only after the peer is verified read the trusted header
(XFF) to derive the username and proceed with empty/exists checks as before.

Comment on lines +801 to +852
#[instrument(skip_all, level = "debug", err, ret)]
pub(crate) async fn check_if_trusted_header_is_valid<Backend: BackendHandler>(
state: &AppState<Backend>,
request: &HttpRequest,
) -> Result<ValidationResults, actix_web::Error> {
if !state.trusted_header_options.enabled {
return Err(ErrorUnauthorized(
"Trusted header authentication is disabled",
));
}

// Validate client IP is in trusted CIDRs
let client_ip =
get_client_ip(request).ok_or_else(|| ErrorUnauthorized("Could not determine client IP"))?;

if !is_ip_in_trusted_proxies(client_ip, &state.trusted_header_options.trusted_proxies) {
return Err(ErrorUnauthorized(format!(
"Client IP {} not in trusted proxies",
client_ip
)));
}

let header_name = &state.trusted_header_options.header_name;
let username = request
.headers()
.get(header_name)
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ErrorUnauthorized(format!("Missing trusted header: {}", header_name)))?;

if username.trim().is_empty() {
return Err(ErrorUnauthorized("Empty username in trusted header"));
}

let user_id = UserId::new(username);

// Get user groups to determine permissions
let groups = state
.get_readonly_handler()
.get_user_groups(&user_id)
.await
.map_err(|_| ErrorUnauthorized("User not found in LLDAP"))?;

Ok(state.backend_handler.get_permissions_from_groups(
user_id,
groups
.iter()
.map(|g| GroupName::from(g.display_name.as_str())),
))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Same critical XFF issue in GraphQL validator. Validate the peer IP first.

Replicate the peer-IP check fix here to prevent XFF spoofing on GraphQL routes.

-    // Validate client IP is in trusted CIDRs
-    let client_ip =
-        get_client_ip(request).ok_or_else(|| ErrorUnauthorized("Could not determine client IP"))?;
-
-    if !is_ip_in_trusted_proxies(client_ip, &state.trusted_header_options.trusted_proxies) {
+    // Validate the immediate upstream (peer) IP is a trusted proxy
+    let peer_ip = request
+        .peer_addr()
+        .map(|s| s.ip())
+        .ok_or_else(|| ErrorUnauthorized("Could not determine peer IP"))?;
+    if !is_ip_in_trusted_proxies(peer_ip, &state.trusted_header_options.trusted_proxies) {
         return Err(ErrorUnauthorized(format!(
-            "Client IP {} not in trusted proxies",
-            client_ip
+            "Peer IP {} not in trusted proxies",
+            peer_ip
         )));
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[instrument(skip_all, level = "debug", err, ret)]
pub(crate) async fn check_if_trusted_header_is_valid<Backend: BackendHandler>(
state: &AppState<Backend>,
request: &HttpRequest,
) -> Result<ValidationResults, actix_web::Error> {
if !state.trusted_header_options.enabled {
return Err(ErrorUnauthorized(
"Trusted header authentication is disabled",
));
}
// Validate client IP is in trusted CIDRs
let client_ip =
get_client_ip(request).ok_or_else(|| ErrorUnauthorized("Could not determine client IP"))?;
if !is_ip_in_trusted_proxies(client_ip, &state.trusted_header_options.trusted_proxies) {
return Err(ErrorUnauthorized(format!(
"Client IP {} not in trusted proxies",
client_ip
)));
}
let header_name = &state.trusted_header_options.header_name;
let username = request
.headers()
.get(header_name)
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ErrorUnauthorized(format!("Missing trusted header: {}", header_name)))?;
if username.trim().is_empty() {
return Err(ErrorUnauthorized("Empty username in trusted header"));
}
let user_id = UserId::new(username);
// Get user groups to determine permissions
let groups = state
.get_readonly_handler()
.get_user_groups(&user_id)
.await
.map_err(|_| ErrorUnauthorized("User not found in LLDAP"))?;
Ok(state.backend_handler.get_permissions_from_groups(
user_id,
groups
.iter()
.map(|g| GroupName::from(g.display_name.as_str())),
))
}
#[instrument(skip_all, level = "debug", err, ret)]
pub(crate) async fn check_if_trusted_header_is_valid<Backend: BackendHandler>(
state: &AppState<Backend>,
request: &HttpRequest,
) -> Result<ValidationResults, actix_web::Error> {
if !state.trusted_header_options.enabled {
return Err(ErrorUnauthorized(
"Trusted header authentication is disabled",
));
}
// Validate the immediate upstream (peer) IP is a trusted proxy
let peer_ip = request
.peer_addr()
.map(|s| s.ip())
.ok_or_else(|| ErrorUnauthorized("Could not determine peer IP"))?;
if !is_ip_in_trusted_proxies(peer_ip, &state.trusted_header_options.trusted_proxies) {
return Err(ErrorUnauthorized(format!(
"Peer IP {} not in trusted proxies",
peer_ip
)));
}
let header_name = &state.trusted_header_options.header_name;
let username = request
.headers()
.get(header_name)
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ErrorUnauthorized(format!("Missing trusted header: {}", header_name)))?;
if username.trim().is_empty() {
return Err(ErrorUnauthorized("Empty username in trusted header"));
}
let user_id = UserId::new(username);
// Get user groups to determine permissions
let groups = state
.get_readonly_handler()
.get_user_groups(&user_id)
.await
.map_err(|_| ErrorUnauthorized("User not found in LLDAP"))?;
Ok(state.backend_handler.get_permissions_from_groups(
user_id,
groups
.iter()
.map(|g| GroupName::from(g.display_name.as_str())),
))
}

@nitnelave
Copy link
Member

Just mentioning that I haven't forgotten about you, but I want to give this PR a proper review since it's quite security-sensitive, and I'm short on free time.

Can you describe the (intended) user flow when logging in through trusted header? What will the first http request look like, what will the response look like, in terms of headers/cookies? What about subsequent same-session requests? What about next-day requests (after JWT expiration)?

@Kumpelinus
Copy link
Contributor Author

Hi, sorry for the late reply. I am busy as well. So, no worries :)

Just mentioning that I haven't forgotten about you, but I want to give this PR a proper review since it's quite security-sensitive, and I'm short on free time.

Can you describe the (intended) user flow when logging in through trusted header? What will the first http request look like, what will the response look like, in terms of headers/cookies? What about subsequent same-session requests? What about next-day requests (after JWT expiration)?

I made it so that the trusted header flow bypasses the jwt flow and thus does not really have a "session" with jwt token or cookies. I personally preferred not having to deal with the jwt cookies but I could change that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants