-
-
Notifications
You must be signed in to change notification settings - Fork 294
auth: Add trusted header authentication with IP allowlist #1285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughAdds 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
📜 Recent review detailsConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (9)
🚧 Files skipped from review as they are similar to previous changes (3)
🧰 Additional context used🧬 Code graph analysis (2)server/src/graphql_server.rs (1)
server/src/auth_service.rs (1)
⏰ 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)
🔇 Additional comments (5)
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. Comment |
|
Fixes #352 |
Codecov Report❌ Patch coverage is
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:
|
There was a problem hiding this 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
TrustedHeaderOptionsintoAppStateandhttp_configare sound. LGTM.- Long
http_configsignature is now a bit unwieldy; consider a smallHttpRuntimeContextstruct in a follow-up to drop thetoo_many_argumentsallow.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 withget_trusted_header_successful_response.You recompute
is_adminhere 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
⛔ Files ignored due to path filters (1)
Cargo.lockis 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 keepingipnet = "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.
| 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)) | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
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/**' -C2Length 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.
| 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 | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| 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 | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| 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.
| 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) | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| #[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())), | ||
| )) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| #[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())), | |
| )) | |
| } |
|
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)? |
464cbf1 to
662dfab
Compare
|
Hi, sorry for the late reply. I am busy as well. So, no worries :)
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. |
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:
Configuration options:
trusted_header_options.enabled- Enable/disable the featuretrusted_header_options.header_name- Header containing username (default: "Remote-User")trusted_header_options.logout_url- Optional logout redirect URLtrusted_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.