Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions tower-http/src/services/fs/serve_dir/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,14 @@ impl ServeVariant {
.components()
.all(|c| matches!(c, Component::Normal(_)))
{
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
if is_reserved_dos_name(|| comp.encode_wide()) {
return None;
}
}

path_to_file.push(comp)
} else {
return None;
Expand All @@ -489,6 +497,117 @@ impl ServeVariant {
}
}

/// Check whether a component name matches a reserved Windows DOS device name.
/// See: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
///
/// We explicitly check for Unicode superscript characters `¹` (0x00B9), `²` (0x00B2),
/// and `³` (0x00B3) because older character tables (ISO/IEC 8859-1) define these values,
/// which legacy Win32 file parsing resolves natively as valid port numbers (0..9).
///
/// This uses an iterator and stack array to avoid allocating. A closure is used because it
/// iterates the characters twice. The closure must return the same iterator each time it is
/// called.
#[cfg(any(windows, test))]
#[cfg(any(windows, test))]
fn is_reserved_dos_name<F, I>(mut get_iter: F) -> bool
where
F: FnMut() -> I,
I: Iterator<Item = u16>,
{
const CON: [u16; 3] = [b'C' as u16, b'O' as u16, b'N' as u16];
const PRN: [u16; 3] = [b'P' as u16, b'R' as u16, b'N' as u16];
const AUX: [u16; 3] = [b'A' as u16, b'U' as u16, b'X' as u16];
const NUL: [u16; 3] = [b'N' as u16, b'U' as u16, b'L' as u16];
const CONIN: [u16; 6] = [
b'C' as u16,
b'O' as u16,
b'N' as u16,
b'I' as u16,
b'N' as u16,
b'$' as u16,
];
const CONOUT: [u16; 7] = [
b'C' as u16,
b'O' as u16,
b'N' as u16,
b'O' as u16,
b'U' as u16,
b'T' as u16,
b'$' as u16,
];

const COM: [u16; 3] = [b'C' as u16, b'O' as u16, b'M' as u16];
const LPT: [u16; 3] = [b'L' as u16, b'P' as u16, b'T' as u16];

const ZERO: u16 = b'0' as u16;
const NINE: u16 = b'9' as u16;
const SUPERSCRIPT_ONE: u16 = 0x00B9;
const SUPERSCRIPT_TWO: u16 = 0x00B2;
const SUPERSCRIPT_THREE: u16 = 0x00B3;

fn is_whitespace(c: u16) -> bool {
c <= 0x7F && ((c as u8).is_ascii_whitespace() || c == 0x000B)
}

// In a first pass over the string, obtain the length of the basename.
let trimmed_len = get_iter()
.enumerate()
// We want the base name, so stop at '.' or ':' characters.
.take_while(|&(_idx, c)| c != b'.' as u16 && c != b':' as u16)
// We want to trim whitespace from the end, so ignore whitespace chars.
.filter(|&(_idx, c)| !is_whitespace(c))
// Get the last non-whitespace char before the first '.'/':' character.
.last()
// Convert index of that char into length of string.
.map(|(idx, _)| idx + 1)
.unwrap_or(0);

// If the trimmed base name is longer than 7, it cannot be a reserved name.
if trimmed_len > 7 {
return false;
}

// At this point, we can store the string in an array, which is more convenient to work with.
let mut buf = [0u16; 7];
get_iter()
.take(trimmed_len)
.enumerate()
.for_each(|(i, c)| buf[i] = c);

for b in &mut buf {
if *b <= 0x7F {
*b = (*b as u8).to_ascii_uppercase() as u16;
}
if *b == SUPERSCRIPT_ONE {
*b = b'1' as u16;
}
if *b == SUPERSCRIPT_TWO {
*b = b'2' as u16;
}
if *b == SUPERSCRIPT_THREE {
*b = b'3' as u16;
}
}
let name = &buf[..trimmed_len];

// Check basic fixed-length strings
if name == CON || name == PRN || name == AUX || name == NUL || name == CONIN || name == CONOUT {
return true;
}

// COMx / LPTx
if name.len() == 4 {
let prefix = &name[..3];
let suffix = name[3];

if (prefix == COM || prefix == LPT) && matches!(suffix, ZERO..=NINE) {
return true;
}
}

false
}

opaque_body! {
/// Response body for [`ServeDir`] and [`ServeFile`][super::ServeFile].
#[derive(Default)]
Expand Down
142 changes: 142 additions & 0 deletions tower-http/src/services/fs/serve_dir/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,3 +877,145 @@ async fn calls_fallback_on_null() {

assert_eq!(res.headers()["from-fallback"], "1");
}

#[cfg(windows)]
fn verify_windows_device(name: &str, is_positive: bool) {
use std::fs::OpenOptions;
use std::os::windows::io::AsRawHandle;

extern "system" {
fn GetFileType(hFile: *mut std::ffi::c_void) -> u32;
}
const FILE_TYPE_CHAR: u32 = 0x0002;

let file_res = OpenOptions::new().read(true).open(name);
if let Ok(file) = file_res {
let handle = file.as_raw_handle();
let file_type = unsafe { GetFileType(handle as _) };
if is_positive {
assert_eq!(
file_type, FILE_TYPE_CHAR,
"Expected Windows to treat {:?} as a system character device",
name
);
} else {
assert_ne!(
file_type, FILE_TYPE_CHAR,
"Expected Windows NOT to treat {:?} as a system character device",
name
);
}
}
}

#[test]
fn test_is_reserved_dos_name() {
use super::is_reserved_dos_name;

let positives = [
"CON",
"con",
"Con",
"PRN",
"Prn",
"AUX",
"aux",
"NUL",
"nul",
"CONIN$",
"conin$",
"CONOUT$",
"ConOut$",
"COM0",
"com0",
"Com0",
"COM1",
"com9",
"Com3",
"COM¹",
"com³",
"LPT0",
"lpt0",
"Lpt0",
"LPT1",
"lpt9",
"Lpt3",
"LPT¹",
"lpt²",
"CON.txt",
"con.anything",
"AUX.tar.gz",
"NUL.",
"COM1:",
"com9.ext:",
"CON ",
"CON ",
"NUL .txt",
"CON\t",
"CON\n",
"CON\r",
"CON \t",
"CON\x0B",
];

for name in positives {
assert!(
is_reserved_dos_name(|| name.encode_utf16()),
"Expected true for {:?}",
name
);

#[cfg(windows)]
verify_windows_device(name, true);
}

let negatives = [
"C0N",
"PRN1",
"AUX42",
"NULL",
"CONIN",
"CONOUT",
"COM10",
"LPT42",
"COMa",
"LPTb",
"safe.txt",
"index.html",
"aux-file.js",
"contact.html",
];

for name in negatives {
assert!(
!is_reserved_dos_name(|| name.encode_utf16()),
"Expected false for {:?}",
name
);

#[cfg(windows)]
verify_windows_device(name, false);
}
}

#[test]
fn test_build_and_validate_path_reserved_dos_names() {
use super::ServeVariant;
use std::path::Path;

let variant = ServeVariant::Directory {
append_index_html_on_directories: true,
};
let base = Path::new("/base");

let reserved = ["/CON", "/CON.txt", "/com0", "/com1", "/com¹", "/CONIN$"];

for path in reserved {
let result = variant.build_and_validate_path(base, path);
if cfg!(windows) {
assert!(result.is_none(), "Expected None for path: {}", path);
} else {
assert!(result.is_some(), "Expected Some for path: {}", path);
}
}
}
Loading