From af173ce1dcac327e852e6b00032b91c8861d12f8 Mon Sep 17 00:00:00 2001 From: John Downey Date: Fri, 8 May 2026 15:22:31 -0500 Subject: [PATCH] Add path traversal protection to serving files example --- examples/src/serving_files.gleam | 60 ++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/examples/src/serving_files.gleam b/examples/src/serving_files.gleam index 09f1a22..0286051 100644 --- a/examples/src/serving_files.gleam +++ b/examples/src/serving_files.gleam @@ -2,8 +2,15 @@ import ewe.{type Response} import gleam/erlang/process import gleam/http/response import gleam/option.{None} +import gleam/string import logging +@external(erlang, "filename", "absname") +fn absname(path: String) -> String + +@external(erlang, "filename", "absname_join") +fn absname_join(dir: String, file: String) -> String + pub fn main() { logging.configure() logging.set_level(logging.Info) @@ -19,26 +26,41 @@ pub fn main() { process.sleep_forever() } +/// Resolves the URL path against the `root` directory and confirms the +/// result stays inside it. Returns `Error(Nil)` for any traversal attempt. +/// +fn safe_path(root: String, path: String) -> Result(String, Nil) { + let public_dir = absname(root) + let relative = string.drop_start(path, 1) + let resolved = absname_join(public_dir, relative) + case string.starts_with(resolved, public_dir <> "/") { + True -> Ok(resolved) + False -> Error(Nil) + } +} + +fn not_found() -> Response { + response.new(404) + |> response.set_header("content-type", "text/plain; charset=utf-8") + |> response.set_body(ewe.TextData("File not found")) +} + fn serve_file(path: String) -> Response { - // Load file from disk using ewe.file(). This efficiently streams the file - // content without loading it entirely into memory. - // - // In production, make sure to validate paths to prevent directory traversal - // attacks! (e.g., requests to "../../../etc/passwd") - // - case ewe.file("public/" <> path, offset: None, limit: None) { - Ok(file) -> { - // Using "application/octet-stream" is safe for any file type, but you - // may want to specify content-type based on file extension in production. + case safe_path("public", path) { + Error(_) -> not_found() + Ok(safe) -> + // Load file from disk using ewe.file(). This efficiently streams the file + // content without loading it entirely into memory. // - response.new(200) - |> response.set_header("content-type", "application/octet-stream") - |> response.set_body(file) - } - Error(_) -> { - response.new(404) - |> response.set_header("content-type", "text/plain; charset=utf-8") - |> response.set_body(ewe.TextData("File not found")) - } + case ewe.file(safe, offset: None, limit: None) { + Ok(file) -> + // Using "application/octet-stream" is safe for any file type, but you + // may want to specify content-type based on file extension in production. + // + response.new(200) + |> response.set_header("content-type", "application/octet-stream") + |> response.set_body(file) + Error(_) -> not_found() + } } }