Skip to content

Commit 3dcddd6

Browse files
fix: prevent SSRF and open redirect in alert targets and OIDC logout
Add endpoint validation for alert targets to block SSRF attacks. - Validates scheme (http/https only) - rejects private/loopback/link-local - IPs for both literal and DNS-resolved addresses, including IPv4-mapped IPv6 and 0.0.0.0/8 - DNS resolution is re-validated and pinned at request time via reqwest's resolve() to prevent DNS rebinding. Validate redirect URL in OIDC logout to prevent open redirect attacks.
1 parent 79656f6 commit 3dcddd6

File tree

3 files changed

+205
-11
lines changed

3 files changed

+205
-11
lines changed

src/alerts/target.rs

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
use std::{
2020
collections::HashMap,
21+
net::{IpAddr, SocketAddr, ToSocketAddrs},
2122
sync::{Arc, Mutex},
2223
time::Duration,
2324
};
@@ -473,12 +474,164 @@ impl TargetType {
473474
TargetType::AlertManager(target) => target.call(payload).await,
474475
}
475476
}
477+
478+
pub fn endpoint_url(&self) -> &Url {
479+
match self {
480+
TargetType::Slack(h) => &h.endpoint,
481+
TargetType::Other(h) => &h.endpoint,
482+
TargetType::AlertManager(h) => &h.endpoint,
483+
}
484+
}
476485
}
477486

478487
fn default_client_builder() -> ClientBuilder {
479488
ClientBuilder::new()
480489
}
481490

491+
fn mask_endpoint(url: &Url) -> String {
492+
let s = url.to_string();
493+
format!("{}********", &s[..4])
494+
}
495+
496+
fn is_private_ip(ip: IpAddr) -> bool {
497+
match ip {
498+
IpAddr::V4(v4) => {
499+
v4.is_loopback()
500+
|| v4.is_private()
501+
|| v4.is_link_local()
502+
|| v4.is_broadcast()
503+
|| v4.is_multicast()
504+
|| v4.is_unspecified()
505+
// "This network" 0.0.0.0/8 — on some systems 0.x.x.x routes to localhost
506+
|| v4.octets()[0] == 0
507+
}
508+
IpAddr::V6(v6) => {
509+
v6.is_loopback()
510+
|| v6.is_multicast()
511+
|| v6.is_unspecified()
512+
// Unique local addresses (ULA): fc00::/7
513+
|| (v6.segments()[0] & 0xfe00) == 0xfc00
514+
// Link-local: fe80::/10
515+
|| (v6.segments()[0] & 0xffc0) == 0xfe80
516+
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
517+
|| v6.to_ipv4_mapped().is_some_and(|v4| is_private_ip(IpAddr::V4(v4)))
518+
}
519+
}
520+
}
521+
522+
/// Validate a target endpoint URL to prevent SSRF.
523+
/// Blocks private/loopback/link-local IP addresses (both literal and DNS-resolved).
524+
pub async fn validate_target_endpoint(url: &Url) -> Result<(), AlertError> {
525+
match url.scheme() {
526+
"http" | "https" => {}
527+
_ => {
528+
return Err(AlertError::CustomError(
529+
"Target endpoint must use http or https scheme".into(),
530+
));
531+
}
532+
}
533+
534+
let host = url
535+
.host()
536+
.ok_or_else(|| AlertError::CustomError("Target endpoint must have a valid host".into()))?;
537+
538+
match host {
539+
url::Host::Ipv4(v4) => {
540+
if is_private_ip(IpAddr::V4(v4)) {
541+
return Err(AlertError::CustomError(
542+
"Target endpoint must not point to a private or internal address".into(),
543+
));
544+
}
545+
}
546+
url::Host::Ipv6(v6) => {
547+
if is_private_ip(IpAddr::V6(v6)) {
548+
return Err(AlertError::CustomError(
549+
"Target endpoint must not point to a private or internal address".into(),
550+
));
551+
}
552+
}
553+
url::Host::Domain(hostname) => {
554+
let port = url.port_or_known_default().unwrap_or(80);
555+
let addr = format!("{hostname}:{port}");
556+
let resolved = tokio::task::spawn_blocking(move || {
557+
addr.to_socket_addrs().map(|i| i.collect::<Vec<_>>())
558+
})
559+
.await
560+
.map_err(|e| AlertError::CustomError(format!("DNS resolution task failed: {e}")))?;
561+
562+
let addrs = resolved.map_err(|e| {
563+
AlertError::CustomError(format!("Could not resolve target endpoint host: {e}"))
564+
})?;
565+
for sock_addr in addrs {
566+
if is_private_ip(sock_addr.ip()) {
567+
return Err(AlertError::CustomError(
568+
"Target endpoint must not point to a private or internal address".into(),
569+
));
570+
}
571+
}
572+
}
573+
}
574+
575+
Ok(())
576+
}
577+
578+
/// Resolve DNS and validate IPs at call time to prevent DNS rebinding.
579+
/// Returns `Some((hostname, addr))` for domain-based URLs (to pin via `ClientBuilder::resolve`),
580+
/// or `None` for IP-literal URLs (no pinning needed).
581+
async fn resolve_and_pin(url: &Url) -> Result<Option<(String, SocketAddr)>, String> {
582+
let host = url.host().ok_or("No host in target URL")?;
583+
584+
match host {
585+
url::Host::Ipv4(v4) => {
586+
if is_private_ip(IpAddr::V4(v4)) {
587+
return Err("Target resolves to a private/internal address".into());
588+
}
589+
Ok(None)
590+
}
591+
url::Host::Ipv6(v6) => {
592+
if is_private_ip(IpAddr::V6(v6)) {
593+
return Err("Target resolves to a private/internal address".into());
594+
}
595+
Ok(None)
596+
}
597+
url::Host::Domain(hostname) => {
598+
let port = url.port_or_known_default().unwrap_or(80);
599+
let addr_str = format!("{hostname}:{port}");
600+
let hostname = hostname.to_string();
601+
let addrs = tokio::task::spawn_blocking(move || {
602+
addr_str.to_socket_addrs().map(|i| i.collect::<Vec<_>>())
603+
})
604+
.await
605+
.map_err(|e| format!("DNS resolution task failed: {e}"))?
606+
.map_err(|e| format!("Could not resolve host: {e}"))?;
607+
608+
for sock_addr in &addrs {
609+
if is_private_ip(sock_addr.ip()) {
610+
return Err("Target resolves to a private/internal address".into());
611+
}
612+
}
613+
614+
let first = addrs
615+
.into_iter()
616+
.next()
617+
.ok_or("DNS resolution returned no addresses")?;
618+
Ok(Some((hostname, first)))
619+
}
620+
}
621+
}
622+
623+
/// Apply DNS pinning to a `ClientBuilder` to prevent DNS rebinding attacks.
624+
/// Resolves the endpoint, validates all IPs, and pins the connection to the validated address.
625+
async fn apply_dns_pinning(
626+
mut builder: ClientBuilder,
627+
endpoint: &Url,
628+
) -> Result<ClientBuilder, String> {
629+
if let Some((host, addr)) = resolve_and_pin(endpoint).await? {
630+
builder = builder.resolve(&host, addr);
631+
}
632+
Ok(builder)
633+
}
634+
482635
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
483636
pub struct SlackWebHook {
484637
endpoint: Url,
@@ -487,7 +640,17 @@ pub struct SlackWebHook {
487640
#[async_trait]
488641
impl CallableTarget for SlackWebHook {
489642
async fn call(&self, payload: &Context) {
490-
let client = default_client_builder()
643+
let builder = match apply_dns_pinning(default_client_builder(), &self.endpoint).await {
644+
Ok(b) => b,
645+
Err(e) => {
646+
error!(
647+
"SSRF protection blocked request to {}: {e}",
648+
mask_endpoint(&self.endpoint)
649+
);
650+
return;
651+
}
652+
};
653+
let client = builder
491654
.build()
492655
.expect("Client can be constructed on this system");
493656

@@ -526,6 +689,16 @@ impl CallableTarget for OtherWebHook {
526689
if self.skip_tls_check {
527690
builder = builder.danger_accept_invalid_certs(true)
528691
}
692+
builder = match apply_dns_pinning(builder, &self.endpoint).await {
693+
Ok(b) => b,
694+
Err(e) => {
695+
error!(
696+
"SSRF protection blocked request to {}: {e}",
697+
mask_endpoint(&self.endpoint)
698+
);
699+
return;
700+
}
701+
};
529702

530703
let client = builder
531704
.build()
@@ -576,6 +749,17 @@ impl CallableTarget for AlertManager {
576749
builder = builder.default_headers(headers)
577750
}
578751

752+
builder = match apply_dns_pinning(builder, &self.endpoint).await {
753+
Ok(b) => b,
754+
Err(e) => {
755+
error!(
756+
"SSRF protection blocked request to {}: {e}",
757+
mask_endpoint(&self.endpoint)
758+
);
759+
return;
760+
}
761+
};
762+
579763
let client = builder
580764
.build()
581765
.expect("Client can be constructed on this system");

src/handlers/http/oidc.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,23 @@ pub async fn login(
167167
}
168168
}
169169

170-
pub async fn logout(req: HttpRequest, query: web::Query<RedirectAfterLogin>) -> HttpResponse {
170+
pub async fn logout(
171+
req: HttpRequest,
172+
query: web::Query<RedirectAfterLogin>,
173+
) -> Result<HttpResponse, OIDCError> {
174+
// Validate redirect URL against server host to prevent open redirect attacks
175+
let conn = req.connection_info().clone();
176+
let base_url_without_scheme = format!("{}/", conn.host());
177+
if !is_valid_redirect_url(&base_url_without_scheme, query.redirect.as_str()) {
178+
return Err(OIDCError::BadRequest(
179+
"Bad Request, Invalid Redirect URL!".to_string(),
180+
));
181+
}
182+
171183
let oidc_client = OIDC_CLIENT.get();
172184

173185
let Some(session) = extract_session_key_from_req(&req).ok() else {
174-
return redirect_to_client(query.redirect.as_str(), None);
186+
return Ok(redirect_to_client(query.redirect.as_str(), None));
175187
};
176188
let tenant_id = get_tenant_id_from_key(&session);
177189
let user = Users.remove_session(&session);
@@ -181,14 +193,14 @@ pub async fn logout(req: HttpRequest, query: web::Query<RedirectAfterLogin>) ->
181193
None
182194
};
183195

184-
match (user, logout_endpoint) {
196+
Ok(match (user, logout_endpoint) {
185197
(Some(username), Some(logout_endpoint))
186198
if Users.is_oauth(&username, &tenant_id).unwrap_or_default() =>
187199
{
188200
redirect_to_oidc_logout(logout_endpoint, &query.redirect)
189201
}
190202
_ => redirect_to_client(query.redirect.as_str(), None),
191-
}
203+
})
192204
}
193205

194206
/// Handler for code callback

src/handlers/http/targets.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use ulid::Ulid;
2626
use crate::{
2727
alerts::{
2828
AlertError,
29-
target::{TARGETS, Target},
29+
target::{TARGETS, Target, validate_target_endpoint},
3030
},
3131
utils::get_tenant_id_from_request,
3232
};
@@ -38,8 +38,7 @@ pub async fn post(
3838
) -> Result<impl Responder, AlertError> {
3939
let tenant_id = get_tenant_id_from_request(&req);
4040
target.tenant = tenant_id;
41-
// should check for duplicacy and liveness (??)
42-
// add to the map
41+
validate_target_endpoint(target.target.endpoint_url()).await?;
4342
TARGETS.update(target.clone()).await?;
4443

4544
// Ok(web::Json(target.mask()))
@@ -88,11 +87,10 @@ pub async fn update(
8887
));
8988
}
9089

91-
// esnure that the supplied target id is assigned to the target config
90+
// ensure that the supplied target id is assigned to the target config
9291
target.id = target_id;
9392
target.tenant = tenant_id;
94-
// should check for duplicacy and liveness (??)
95-
// add to the map
93+
validate_target_endpoint(target.target.endpoint_url()).await?;
9694
TARGETS.update(target.clone()).await?;
9795

9896
// Ok(web::Json(target.mask()))

0 commit comments

Comments
 (0)