byte[] all = ex.getRequestBody().readAllBytes(); // 500MB 업로드 = 500MB 힙
int idx = indexOf(all, boundary);동시 업로드 몇 개면 OOM 입니다. 크기 제한도 "다 읽은 뒤" 검사하게 되어 의미가 없습니다. Spring 에서 MultipartFile.getBytes() 를 습관적으로 쓰는 것도 같은 문제입니다.
✅ 파트를 InputStream 으로 받아 그대로 디스크·파서로 흘려보냅니다.
new MultipartParser(ex.getRequestBody(), boundary).parse(part -> store.save(part.filename(), part.body()));
// Spring: file.getInputStream() 또는 file.transferTo(path). getBytes() 는 작은 파일에만.Path dest = uploadDir.resolve(part.filename()); // filename = "../../webapps/ROOT/shell.jsp"
Files.copy(part.body(), dest);resolve 는 .. 을 해석하지 않으므로 업로드 디렉터리 밖에 파일이 생깁니다. 웹 루트에 JSP 를 떨어뜨리면 원격 코드 실행입니다.
✅ 저장명은 서버가 만든 UUID, 원본명은 정규화해서 메타로만 보관합니다. 다운로드 시에도 id 형식을 검사합니다.
String name = FileStore.sanitize(part.filename()); // 경로 부분 제거
Path dest = uploadDir.resolve(UUID.randomUUID().toString());
// 다운로드: if (!ID_PATTERN.matcher(id).matches()) return 404;if (name.endsWith(".exe") || name.endsWith(".jsp")) reject(); // ".JSP", ".jspx", ".phtml" 통과
if (!part.contentType().startsWith("image/")) reject(); // 헤더는 클라이언트가 마음대로 씀✅ 허용 확장자 화이트리스트 + 매직 넘버로 실제 내용 검사. MIME 은 서버가 확장자로 결정합니다.
if (!ALLOWED.contains(ext)) throw new UploadException(400, "허용되지 않는 확장자");
checkMagic(ext, first8Bytes); // .png 인데 89 50 4E 47 이 아니면 거절
String type = MIME.getOrDefault(ext, "application/octet-stream");h.set("Content-Disposition", "attachment; filename=" + URLEncoder.encode(name, "UTF-8")); // "%ED%9A%8C+..." 로 저장됨
h.set("Content-Disposition", "attachment; filename=\"" + new String(name.getBytes("UTF-8"), "ISO-8859-1") + "\""); // 브라우저별 제각각첫 번째는 브라우저가 퍼센트 문자열을 파일명으로 그대로 씁니다. 두 번째는 헤더에 비 ASCII 바이트를 싣는 편법이라 HttpServer 가 거부하거나 브라우저마다 다르게 해석합니다.
✅ RFC 5987 filename*=UTF-8''... 과 ASCII 대체 filename 을 함께 보냅니다.
String utf8 = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
h.set("Content-Disposition", "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + utf8);
// Spring: ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8).build()if (tooLarge) { HtmlPages.send(ex, 413, ...); return; } // 클라이언트는 아직 3MB 를 보내는 중서버가 응답 후 연결을 닫으면 클라이언트는 쓰기 도중 Connection reset 예외를 받고 413 본문을 보지 못합니다. "업로드하면 알 수 없는 오류" 로 보고됩니다.
✅ 남은 본문을 소비한 뒤 응답합니다. 한도가 아주 크면(수 GB) 소비 대신 연결을 끊는 것이 맞고, 그때는 클라이언트가 리셋을 보는 것을 감수합니다.
ex.getRequestBody().transferTo(OutputStream.nullOutputStream());
HtmlPages.send(ex, 413, "text/plain; charset=UTF-8", "크기 초과");HttpServer server = HttpServer.create(addr, 0);
server.start(); // executor = null → 단일 스레드업로드 하나가 진행되는 동안 다른 모든 요청이 줄을 섭니다. 개발 중에는 눈치채지 못하고 배포 후 "가끔 멈춘다" 로 나타납니다.
✅ 가상 스레드(JDK 21) 또는 고정 풀을 지정합니다.
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // 또는 newFixedThreadPool(32)ex.sendResponseHeaders(200, size);
ex.getResponseBody().write(bytes); // close 없음 → 응답이 끝나지 않음
ex.sendResponseHeaders(200, 0); out.write(1바이트) // 0 = chunked. 길이 다르면 브라우저가 대기/오류✅ sendResponseHeaders 의 길이와 실제 쓰는 바이트 수를 맞추고, 본문 스트림은 try-with-resources 로 닫습니다. 본문이 없으면 -1.
ex.sendResponseHeaders(200, len);
try (OutputStream out = ex.getResponseBody()) { in.transferTo(out); }