PeterPierinakos / vanilla-rustlang-server

Simple, minimal and open source static web server that uses no dependencies.
Apache License 2.0
38 stars 1 forks source link

Server allows path traversal #3

Closed tibordp closed 2 years ago

tibordp commented 2 years ago

Describe the bug vrs serves files that are outside /var/www/static by using .. in path. This allows potentially sensistive files to be read from the server, for example /etc/passwd

To Reproduce

> sudo ./setup.sh
> cargo build
> sudo ./target/debug/vrs &
> echo "GET /../../../etc/passwd HTTP/1.1" | nc localhost 80
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1322

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
vscode:x:1000:1000::/home/vscode:/bin/bash
systemd-timesync:x:101:103:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
systemd-network:x:102:104:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:103:105:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:104:106::/nonexistent:/usr/sbin/nologin
sshd:x:105:65534::/run/sshd:/usr/sbin/nologin

Expected behavior 404 or 400 error

alphabitserial commented 2 years ago

See this example code from Axum for one method of preventing path traversal attacks.

PeterPierinakos commented 2 years ago

Dang I didn't think of that. I'll work on a fix now.

PeterPierinakos commented 2 years ago

EDIT: Deprecated solution, look at the comment below.

@alphabitserial it seems like I have found a simpler fix although it seems a little junky.

tl;dr: potential fix

Why does this work?

HTML files end with a dot at the end. As a result, if the buffer's requested URI contains more than 1 dot, that means they are trying to use an incorrect path. BTW, I tried exploiting it by using the absolute path and it seems not to work.

If you guys see any problem or do not like this solution, I will try to implement @alphabitserial's solution. Otherwise, I will merge with main.

PeterPierinakos commented 2 years ago

Due to some file formats using more than one dot, I have come to the conclusion that @alphabitserial solution is the optimal one.

Final fix

I have put both of you in the credits. If you see any problem with this final fix, please report it so that I can fix it before merging with the main branch.

PeterPierinakos commented 2 years ago

Seems like there are no flaws with this solution. The fix will be merged to the main branch soon. Thanks once again for reporting this vulnerability.

mcronce commented 2 years ago

I just found this from a Reddit link, and obviously might be misreading, but doesn't this solution still allow (e.g.) a path like a/../../b to traverse above the configured root?

This is a bit more heavyweight, but it's code I've written elsewhere adapted to std::path and should be fully robust, if suboptimal:

// Could return an Option<Cow<'_, Path>> if you want to avoid an allocation in cases where the path is already normalized
// Could also just return a bool if you only want this to be a check for validity, which seems good for your use case
fn normalize_path(path: &Path) -> Option<PathBuf> {
    let mut result = PathBuf::new();
    let mut components = path.components();
    let first_component = components.next();
    match first_component {
        Some(Component::Prefix(p)) => result.push(Component::Prefix(p)),
        Some(Component::RootDir) => result.push(Component::RootDir), // If you've already stripped the leading / from the requested path, this should also return None
        _ => return None
    };
    for component in components {
        match component {
            Component::Prefix(_) => return None, // Should be unreachable
            Component::RootDir => return None, // Should be unreachable
            Component::CurDir => if result.as_str().is_empty() {
                // If you've already stripped the leading / from the requested path, this should no-op
                result.push(Component::RootDir);
            },
            Component::ParentDir => if !result.pop() {
                return None;
            },
            Component::Normal(p) => result.push(p)
        {;
    }
    Some(result)
}
PeterPierinakos commented 2 years ago

@mcronce you are right. It is still exploitable. I'll test your solution which seems to be improved and push it to main.

PeterPierinakos commented 2 years ago

By the way, I had to make a few change to your code. At line 14, you tried to call the .as_str() function for a PathBuf type. There is no such function for PathBuf, I am assuming you meant to call .as_os_str() before checking whether it's empty :)

PeterPierinakos commented 2 years ago

Here is the final solution by the way (optimized it a little bit, still works)

pub fn path_is_valid(path: &Path) -> bool {
        let mut result = PathBuf::new();
        let components = path.components();

        for component in components {
            match component {
                Component::Prefix(_) => return false, // Should be unreachable
                Component::RootDir => return false, // Should be unreachable
                Component::CurDir => if result.as_os_str().is_empty() {
                    // If you've already stripped the leading / from the requested path, this should no-op
                    result.push(Component::RootDir);
                },
                Component::ParentDir => if !result.pop() {
                    return false;
                },
                Component::Normal(p) => result.push(p)
            };
        }
        true
    }
mcronce commented 2 years ago

Whoops, good call - yeah should have been .as_os_str(). I adapted this from code that uses camino and did not try to compile before posting it ;)

I'm glad you were able to make use of it. You can actually optimize further by eliminating the result PathBuf entirely in favor of just incrementing and decrementing an integer since you're only looking to get a bool out of it :)

Something like this:

pub fn path_is_valid(path: &Path) -> bool {
    let mut stack = 0;
    let components = path.components();

    for component in components {
        match component {
            Component::Prefix(_) => return false, // Should be unreachable
            Component::RootDir => return false, // Should be unreachable
            Component::CurDir => if stack == 0 {
                // If you've already stripped the leading / from the requested path, this should no-op
                stack += 1;
            },
            Component::ParentDir if stack == 0 => return false,
            Component::ParentDir => stack -= 1,
            Component::Normal(p) => stack += 1
        };
    }
    true
}

I translated it directly from your code, but that Component::CurDir arm does smell a little sus; based on what you posted it looks like you've already made the path non-absolute, which suggests that should just no-op, but I haven't read the code so don't just take my word for it ;) this is complex enough logic that if it were me I'd probably throw together a few tests to validate.

mcronce commented 2 years ago

If I'm right about the CurDir arm (which is a big if), this would probably be correct:

pub fn path_is_valid(path: &Path) -> bool {
    let mut stack = 0;
    let components = path.components();

    for component in components {
        stack += match component {
            Component::Prefix(_) => return false, // Should be unreachable
            Component::RootDir => return false, // Should be unreachable
            Component::CurDir => 0,
            Component::ParentDir if stack == 0 => return false,
            Component::ParentDir => -1,
            Component::Normal(p) => 1
        };
    }
    true
}
PeterPierinakos commented 2 years ago

I will probably create a future-improvements branch with minor changes like this to be merged with the main branch for the next release.

PeterPierinakos commented 2 years ago

Seems like there are no more complaints about path traversal exploits anymore, locking the conversation. Feel free to submit a new issue if a new path traversal vulnerability is found