Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VRLbHYdXVEBzGVhYq3pz4q
93 lines
4.2 KiB
Java
93 lines
4.2 KiB
Java
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<String> 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<String> 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<String> 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");
|
|
}
|
|
}
|