Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

prevent traversal attack #90

Merged
merged 4 commits into from
Jul 4, 2023
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
122 changes: 102 additions & 20 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub struct EncryptConfig {

#[derive(Debug, Clone)]
pub struct HttpConfig {
pub upstream_base_url: String,
pub upstream_base_url: Url,
pub keyring: Keyring,
pub chunk_size: usize,
pub address: SocketAddr,
Expand Down Expand Up @@ -121,14 +121,16 @@ impl Config {
)
});

let upstream_base_url = match &args.flag_upstream_url {
let raw_upstream_base_url = match &args.flag_upstream_url {
Some(upstream_url) => Some(upstream_url.to_string()),
None => Some(env::var("DS_UPSTREAM_URL").expect(
"Missing upstream_url, use DS_UPSTREAM_URL env or --upstream-url cli argument",
)),
}
.unwrap();

let upstream_base_url = normalize_and_parse_upstream_url(raw_upstream_base_url);

let address = match &args.flag_address {
Some(address) => match address.to_socket_addrs() {
Ok(mut sockets) => Some(sockets.next().unwrap()),
Expand All @@ -155,16 +157,36 @@ impl Config {
}
}

// ensure upstream_url ends with a "/ to avoid
// upstream url: "https://upstream/dir"
// request: "https://proxy/file"
// "https://upstream/dir".join('file') => https://upstream/file
// instead ".../upstream/dir/".join('file') => https://upstream/dir/file
fn normalize_and_parse_upstream_url(mut url: String) -> Url {
if !url.ends_with('/') {
url.push('/');
}
Url::parse(&url).unwrap()
}

impl HttpConfig {
pub fn create_upstream_url(&self, req: &HttpRequest) -> String {
let base = Url::parse(self.upstream_base_url.as_ref()).unwrap();
let mut url = base.join(&req.match_info()["name"]).unwrap();
pub fn create_upstream_url(&self, req: &HttpRequest) -> Option<String> {
// Warning: join process '../'
// "https://a.com/jail/".join('../escape') => "https://a.com/escape"
let mut url = self
.upstream_base_url
.join(&req.match_info()["name"])
.unwrap();

if self.is_traversal_attack(&url) {
return None;
}

if !req.query_string().is_empty() {
url.set_query(Some(req.query_string()));
}

url.to_string()
Some(url.to_string())
}

pub fn local_encryption_path_for(&self, req: &HttpRequest) -> PathBuf {
Expand All @@ -173,6 +195,27 @@ impl HttpConfig {
filepath.push(name);
filepath
}

fn is_traversal_attack(&self, url: &Url) -> bool {
// https://upstream.com => [Some("")]
// https://upstream.com/jail/cell/ => [Some("jail"), Some("cell"), Some("")]
let mut base_segments: Vec<&str> =
self.upstream_base_url.path_segments().unwrap().collect();

// remove the last segment corresponding to "/"
base_segments.pop();

let mut url_segments = url.path_segments().unwrap();

// ensure that all segment of the upstream_base_url
// are present in the final url
let safe = base_segments.iter().all(|base_segment| {
let url_segment = url_segments.next().unwrap();
base_segment == &url_segment
});

!safe
}
}

fn read_file_content(path_string: &str) -> String {
Expand All @@ -189,36 +232,75 @@ mod tests {
use super::*;
use actix_web::test::TestRequest;

#[test]
fn test_normalize_and_parse_upstream_url() {
assert_eq!(
normalize_and_parse_upstream_url("https://upstream.com/dir".to_string()),
Url::parse("https://upstream.com/dir/").unwrap()
);
}

#[test]
fn test_create_upstream_url() {
let req = TestRequest::default()
.uri("https://proxy.com/bucket/file.zip?p1=ok1&p2=ok2")
.param("name", "bucket/file.zip") // hack to force parsing
let base = "https://upstream.com/";
let jailed_base = "https://upstream.com/jail/cell/";

let config = default_config(base);
let jailed_config = default_config(jailed_base);

let file = TestRequest::default()
.uri("https://proxy.com/file")
.param("name", "file") // hack to force parsing
.to_http_request();

assert_eq!(
config.create_upstream_url(&file),
Some("https://upstream.com/file".to_string())
);

assert_eq!(
jailed_config.create_upstream_url(&file),
Some("https://upstream.com/jail/cell/file".to_string())
);

let sub_dir_file = TestRequest::default()
.uri("https://proxy.com/sub/dir/file")
.param("name", "sub/dir/file") // hack to force parsing
.to_http_request();

assert_eq!(
default_config("https://upstream.com").create_upstream_url(&req),
"https://upstream.com/bucket/file.zip?p1=ok1&p2=ok2"
config.create_upstream_url(&sub_dir_file),
Some("https://upstream.com/sub/dir/file".to_string())
);

assert_eq!(
default_config("https://upstream.com/").create_upstream_url(&req),
"https://upstream.com/bucket/file.zip?p1=ok1&p2=ok2"
jailed_config.create_upstream_url(&sub_dir_file),
Some("https://upstream.com/jail/cell/sub/dir/file".to_string())
);

let path_traversal_file = TestRequest::default()
.uri("https://proxy.com/../escape")
.param("name", "../escape") // hack to force parsing
.to_http_request();

assert_eq!(
default_config("https://upstream.com/sub_folder/").create_upstream_url(&req),
"https://upstream.com/sub_folder/bucket/file.zip?p1=ok1&p2=ok2"
config.create_upstream_url(&path_traversal_file),
Some("https://upstream.com/escape".to_string())
);

let req = TestRequest::default()
.uri("https://proxy.com/bucket/file.zip")
assert_eq!(
jailed_config.create_upstream_url(&path_traversal_file),
None
);

let file_with_query_string = TestRequest::default()
.uri("https://proxy.com/bucket/file.zip?p1=ok1&p2=ok2")
.param("name", "bucket/file.zip") // hack to force parsing
.to_http_request();

assert_eq!(
default_config("https://upstream.com").create_upstream_url(&req),
"https://upstream.com/bucket/file.zip"
config.create_upstream_url(&file_with_query_string),
Some("https://upstream.com/bucket/file.zip?p1=ok1&p2=ok2".to_string())
);
}

Expand All @@ -228,7 +310,7 @@ mod tests {
HttpConfig {
keyring,
chunk_size: DEFAULT_CHUNK_SIZE,
upstream_base_url: upstream_base_url.to_string(),
upstream_base_url: normalize_and_parse_upstream_url(upstream_base_url.to_string()),
address: "127.0.0.1:1234".to_socket_addrs().unwrap().next().unwrap(),
local_encryption_directory: PathBuf::from(DEFAULT_LOCAL_ENCRYPTION_DIRECTORY),
}
Expand Down
6 changes: 5 additions & 1 deletion src/http/handlers/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ pub async fn fetch(
) -> Result<HttpResponse, Error> {
let get_url = config.create_upstream_url(&req);

if get_url.is_none() {
return not_found();
}

let mut fetch_req = client
.request_from(get_url.as_str(), req.head())
.request_from(get_url.unwrap(), req.head())
.force_close();

let raw_range = req
Expand Down
6 changes: 5 additions & 1 deletion src/http/handlers/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ pub async fn forward(
) -> Result<HttpResponse, Error> {
let put_url = config.create_upstream_url(&req);

if put_url.is_none() {
return not_found();
}

let mut forwarded_req = client
.request_from(put_url.as_str(), req.head())
.request_from(put_url.unwrap(), req.head())
.force_close()
.timeout(UPLOAD_TIMEOUT);

Expand Down
8 changes: 8 additions & 0 deletions src/http/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,11 @@ pub static FETCH_REQUEST_HEADERS_TO_REMOVE: [header::HeaderName; 2] = [
header::CONNECTION,
header::RANGE,
];

pub fn not_found() -> Result<HttpResponse, Error> {
let response = HttpResponse::NotFound()
.insert_header((header::CONTENT_TYPE, "application/json"))
.body("{}");

Ok(response)
}
6 changes: 5 additions & 1 deletion src/http/handlers/simple_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ pub async fn simple_proxy(
) -> Result<HttpResponse, Error> {
let url = config.create_upstream_url(&req);

let mut proxied_req = client.request_from(url.as_str(), req.head()).force_close();
if url.is_none() {
return not_found();
}

let mut proxied_req = client.request_from(url.unwrap(), req.head()).force_close();

for header in &FETCH_REQUEST_HEADERS_TO_REMOVE {
proxied_req.headers_mut().remove(header);
Expand Down
8 changes: 5 additions & 3 deletions tests/concurrent_uploads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ fn concurent_uploads() {

let stored_filename = Uuid::new_v4();
let stored_file_url = format!("localhost:4444/upstream/{}", stored_filename);
let uploaded_path =
format!("tests/fixtures/server-static/uploads/{}", stored_filename);
let uploaded_path = format!(
"tests/fixtures/server-static/uploads/jail/cell/{}",
stored_filename
);

let temp = assert_fs::TempDir::new().unwrap();
let decrypted_file = temp.child("computer.dec.svg");
Expand All @@ -79,7 +81,7 @@ fn concurent_uploads() {
assert_eq!(curl_socket_download.stdout, COMPUTER_SVG_BYTES);

let curl_chunked_download = curl_get(&format!(
"localhost:4444/upstream/chunked/{}",
"localhost:4444/upstream/{}?chunked=true",
stored_filename
));
assert_eq!(curl_chunked_download.stdout.len(), COMPUTER_SVG_BYTES.len());
Expand Down
2 changes: 1 addition & 1 deletion tests/content_length_and_transfert_encoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async fn uploaded_and_downloaded_content_length(content: &[u8]) -> (usize, usize

curl_put("/tmp/foo", "localhost:4444/upstream/file");

let last_put_headers = curl_get("localhost:4444/upstream/last_put_headers").stdout;
let last_put_headers = curl_get("localhost:3333/last_put_headers").stdout;

let deserialized: TestHeaders = serde_json::from_slice(&last_put_headers).unwrap();

Expand Down
2 changes: 1 addition & 1 deletion tests/download_witness_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ fn download_witness_file() {
- copy a witness file in the right directory to be downloaded
- downloads the uploaded file via the proxy, and checks that its content matches the initial content
*/
let uploaded_path = "tests/fixtures/server-static/uploads/computer.svg.enc";
let uploaded_path = "tests/fixtures/server-static/uploads/jail/cell/computer.svg.enc";

std::fs::copy(ENCRYPTED_COMPUTER_SVG_PATH, uploaded_path).expect("copy failed");

Expand Down
22 changes: 13 additions & 9 deletions tests/fixtures/server-static/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,6 @@ app.get('/last_put_headers', function(req, res){
res.json(last_put_headers);
});

app.get('/chunked/*', function(req, res){
const path = req.url.substr(8)

const readStream = fs.createReadStream(__dirname + '/uploads/' + path, { highWaterMark: 1 * 1024});

res.writeHead(200, {'Content-Type': 'text/plain'});
readStream.pipe(res);
});

app.get('/get/500', function(req, res){
res.writeHead(500, {'Content-Type': 'text/plain'});
res.end('KO: 500');
Expand All @@ -71,5 +62,18 @@ app.get('/get/400', function(req, res){
res.end('KO: 400');
});

// return a file by chunked if the query param chunked is present
const chunked_static = function (req, res, next) {
if (!req.query.chunked) {
return next();
}

const path = req.path.substr(1);
const readStream = fs.createReadStream(__dirname + '/uploads/' + path, { highWaterMark: 1 * 1024});
res.writeHead(200, {'Content-Type': 'text/plain'});
readStream.pipe(res);
}

app.use(chunked_static);
app.use(express.static(__dirname + '/uploads'));
app.listen(3333);
1 change: 1 addition & 0 deletions tests/fixtures/server-static/uploads/out_of_jail.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fail
1 change: 1 addition & 0 deletions tests/helpers/curl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub fn curl_get_status(url: &str) -> String {
let stdout = Command::new("curl")
.arg("-XGET")
.arg(url)
.arg("--path-as-is")
.arg("-o")
.arg("/dev/null")
.arg("-s")
Expand Down
2 changes: 1 addition & 1 deletion tests/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ pub fn launch_proxy(log: PrintServerLogs, keyring_path: Option<&str>) -> ChildGu
command
.arg("proxy")
.arg("--address=localhost:4444")
.arg("--upstream-url=http://localhost:3333")
.arg("--upstream-url=http://localhost:3333/jail/cell")
.env("DS_KEYRING", keyring)
.env("DS_PASSWORD", PASSWORD)
.env("DS_SALT", SALT)
Expand Down
5 changes: 4 additions & 1 deletion tests/keyring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ fn multiple_keys() {
add_a_key(keyring_path);

let upload_url = format!("localhost:4444/upstream/victory_{}", i);
let upload_path = format!("tests/fixtures/server-static/uploads/victory_{}", i);
let upload_path = format!(
"tests/fixtures/server-static/uploads/jail/cell/victory_{}",
i
);
ensure_is_absent(&upload_path);

let _proxy_and_node = ProxyAndNode::start_with_keyring_path(keyring_path);
Expand Down
14 changes: 14 additions & 0 deletions tests/traversal_attack.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
use serial_test::serial;

mod helpers;
pub use helpers::*;

#[test]
#[serial(servers)]
fn traversal_attack_is_avoided() {
let _proxy_and_node = ProxyAndNode::start();

let curl_download = curl_get_status("localhost:4444/upstream/../../out_of_jail.txt");
println!("curl_download: {:?}", curl_download);
assert_eq!(curl_download, "404");
}
4 changes: 2 additions & 2 deletions tests/upload_and_download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ fn upload_and_download() {
- decrypt the uploaded file by the decrypted command and check the result
- downloads the uploaded file via the proxy, and checks that its content matches the initial content
*/
let uploaded_path = "tests/fixtures/server-static/uploads/victory";
let uploaded_path = "tests/fixtures/server-static/uploads/jail/cell/victory";

let temp = assert_fs::TempDir::new().unwrap();
let decrypted_file = temp.child("computer.dec.svg");
Expand Down Expand Up @@ -45,7 +45,7 @@ fn upload_and_download() {
let curl_socket_download = curl_socket_get("localhost:4444/upstream/victory");
assert_eq!(curl_socket_download.stdout, COMPUTER_SVG_BYTES);

let curl_chunked_download = curl_get("localhost:4444/upstream/chunked/victory");
let curl_chunked_download = curl_get("localhost:4444/upstream/victory?chunked=true");
assert_eq!(curl_chunked_download.stdout, COMPUTER_SVG_BYTES);

temp.close().unwrap();
Expand Down