package com.ankurm.filetransfer; import static org.assertj.core.api.Assertions.assertThat; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import com.ankurm.filetransfer.Cap.Part; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class FilenameAndDownloadTest { static final String F = "02-filename.txt"; static final String G = "03-download.txt"; @LocalServerPort int port; @Autowired FileStore store; @BeforeAll static void clean() { Cap.reset(F); Cap.reset(G); } String base() { return "http://localhost:" + port; } String postRaw(String filename, String contentType, byte[] content) throws Exception { String boundary = "----ft" + System.nanoTime(); byte[] body = Cap.multipart(boundary, List.of(new Part("file", filename, contentType, content))); HttpRequest req = HttpRequest.newBuilder(URI.create(base() + "/files")) .header("Content-Type", "multipart/form-data; boundary=" + boundary) .POST(HttpRequest.BodyPublishers.ofByteArray(body)).build(); return Cap.HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body(); } @Test void whatTheServerSeesIsWhatTheClientSent() throws Exception { String evil = "../../etc/cron.d/evil.txt"; String json = postRaw(evil, "image/png", "hello".getBytes(StandardCharsets.UTF_8)); Cap.say(F, "client sent filename=" + evil + " Content-Type=image/png"); Cap.say(F, "server response: " + json); Path root = store.root(); boolean escapes = !root.resolve(evil).normalize().startsWith(root); Cap.say(F, "root.resolve(originalFilename).normalize() stays inside the storage dir: " + !escapes); assertThat(json).contains("\"displayName\":\"evil.txt\""); assertThat(escapes).isTrue(); String win = postRaw("..\\..\\windows\\system32\\evil.dll", "text/plain", "x".getBytes(StandardCharsets.UTF_8)); Cap.say(F, "client sent a Windows-style path; server response: " + win); } @Test void downloadSupportsRangeAndEncodesNames() throws Exception { String json = postRaw("résumé 2026.txt", "text/plain", "ABCDEFGHIJKLMNOPQRSTUVWXYZ".getBytes(StandardCharsets.UTF_8)); String id = json.replaceAll(".*\"id\":\"([^\"]+)\".*", "$1"); HttpResponse full = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id)).build(), HttpResponse.BodyHandlers.ofString()); Cap.say(G, "GET /files/{id}"); Cap.say(G, " status=" + full.statusCode() + " body=" + full.body()); for (String h : List.of("content-type", "content-length", "content-disposition", "accept-ranges", "x-content-type-options")) { Cap.say(G, " " + h + ": " + full.headers().firstValue(h).orElse("(absent)")); } HttpResponse part = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id)) .header("Range", "bytes=10-19").build(), HttpResponse.BodyHandlers.ofString()); Cap.say(G, "GET /files/{id} with Range: bytes=10-19"); Cap.say(G, " status=" + part.statusCode() + " body=" + part.body()); Cap.say(G, " content-range: " + part.headers().firstValue("content-range").orElse("(absent)")); HttpResponse past = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id)) .header("Range", "bytes=500-").build(), HttpResponse.BodyHandlers.ofString()); Cap.say(G, "GET /files/{id} with Range: bytes=500-"); Cap.say(G, " status=" + past.statusCode() + " content-range: " + past.headers().firstValue("content-range").orElse("(absent)")); assertThat(part.statusCode()).isEqualTo(206); assertThat(part.body()).isEqualTo("KLMNOPQRST"); } }