Release v0.6.0
This commit is contained in:
1 parent
7069ca9db7
commit
f49698bb5c
75 files changed
+11199
-7508
No files matched your search
@@ -17,3 +17,144 @@ pub fn validate_redirect_code(code: &str) -> bool {
|
||||
pub fn validate_page_code(code: &str) -> bool {
|
||||
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
||||
}
|
||||
|
||||
/// Classification of a stored or proposed redirect destination.
|
||||
///
|
||||
/// Used by write-path validation and read-only legacy data audits. Order of checks
|
||||
/// matches `validate_redirect_destination` so both share the same rules.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DestinationClass {
|
||||
ValidHttp,
|
||||
ValidHttps,
|
||||
Empty,
|
||||
TooLong,
|
||||
ControlCharacters,
|
||||
NonAscii,
|
||||
UnsupportedScheme,
|
||||
Malformed,
|
||||
}
|
||||
|
||||
impl DestinationClass {
|
||||
pub fn is_valid(self) -> bool {
|
||||
matches!(self, Self::ValidHttp | Self::ValidHttps)
|
||||
}
|
||||
}
|
||||
|
||||
fn scheme_prefix(dest: &str) -> Option<&str> {
|
||||
let end = dest.find(':')?;
|
||||
let scheme = &dest[..end];
|
||||
if scheme.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if scheme
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
|
||||
{
|
||||
Some(scheme)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a destination using the same rules as write-path validation.
|
||||
pub fn classify_redirect_destination(destination: &str) -> DestinationClass {
|
||||
let dest = destination.trim();
|
||||
if dest.is_empty() {
|
||||
return DestinationClass::Empty;
|
||||
}
|
||||
if dest.len() > 2048 {
|
||||
return DestinationClass::TooLong;
|
||||
}
|
||||
// HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls.
|
||||
if dest.bytes().any(|b| b < 0x20 || b == 0x7f) {
|
||||
return DestinationClass::ControlCharacters;
|
||||
}
|
||||
// HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII.
|
||||
if !dest.is_ascii() {
|
||||
return DestinationClass::NonAscii;
|
||||
}
|
||||
// Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:).
|
||||
if let Some(scheme) = scheme_prefix(dest) {
|
||||
let scheme_l = scheme.to_ascii_lowercase();
|
||||
if scheme_l != "http" && scheme_l != "https" {
|
||||
return DestinationClass::UnsupportedScheme;
|
||||
}
|
||||
}
|
||||
match reqwest::Url::parse(dest) {
|
||||
Ok(url) => {
|
||||
if url.host_str().is_none() {
|
||||
return DestinationClass::Malformed;
|
||||
}
|
||||
match url.scheme() {
|
||||
"http" => DestinationClass::ValidHttp,
|
||||
"https" => DestinationClass::ValidHttps,
|
||||
_ => DestinationClass::UnsupportedScheme,
|
||||
}
|
||||
}
|
||||
Err(_) => DestinationClass::Malformed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `destination` is safe to store and emit as an HTTP Location value.
|
||||
///
|
||||
/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values,
|
||||
/// and non-http(s) schemes. The redirect handler remains defensive even if invalid
|
||||
/// values already exist in older data.
|
||||
pub fn validate_redirect_destination(destination: &str) -> bool {
|
||||
classify_redirect_destination(destination).is_valid()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_crlf_destination() {
|
||||
assert!(!validate_redirect_destination(
|
||||
"https://example.com/\r\nX-Injected: 1"
|
||||
));
|
||||
assert!(!validate_redirect_destination(
|
||||
"https://example.com/\nX-Injected: 1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_schemes() {
|
||||
assert!(!validate_redirect_destination("javascript:alert(1)"));
|
||||
assert!(!validate_redirect_destination("data:text/html,hi"));
|
||||
assert!(!validate_redirect_destination("/relative/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_https() {
|
||||
assert!(validate_redirect_destination(
|
||||
"https://example.com/path?q=1#frag"
|
||||
));
|
||||
assert!(validate_redirect_destination("http://localhost:8080/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_destination_categories() {
|
||||
assert_eq!(
|
||||
classify_redirect_destination("https://ok.example/"),
|
||||
DestinationClass::ValidHttps
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("http://ok.example/"),
|
||||
DestinationClass::ValidHttp
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("javascript:alert(1)"),
|
||||
DestinationClass::UnsupportedScheme
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("https://x/\r\nX:1"),
|
||||
DestinationClass::ControlCharacters
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("not a url"),
|
||||
DestinationClass::Malformed
|
||||
);
|
||||
assert_eq!(classify_redirect_destination(""), DestinationClass::Empty);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user