본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 보안과 방어적 운영

입력 크기·파일·쿼리 방어

Jakarta Validation은 필드 형식과 값 범위를 검사하지만, 서버가 본문 전체를 읽기 전에 메모리와 네트워크를 얼마나 쓰게 할지는 결정하지 않습니다. 매우 큰 JSON 배열, multipart 파일, 임의 정렬 열과 넓은 검색 조건은 형식상 유효해도 자원을 고갈시킬 수 있습니다.


유효한 입력도 너무 크면 공격이 된다

방어는 여러 층에 둡니다. proxy와 서버는 요청 전체 크기를 제한하고, multipart 설정은 파일별 상한을 적용하며, Controller는 허용한 content type과 업무 크기를 확인합니다. 목록 API는 page size와 정렬 필드를 whitelist로 제한하고 DB timeout으로 최악의 실행 시간을 막습니다.

한 층의 제한만 믿지 않습니다. proxy 제한은 로컬 실행에서 우회될 수 있고, Controller 검사는 multipart parser가 이미 큰 본문을 읽은 뒤에야 실행될 수 있습니다.


크기와 whitelist 제한을 코드에 둔다

예제는 2MB 이하의 텍스트와 PDF만 받고, 클라이언트가 보낸 파일 이름을 저장 경로에 사용하지 않습니다. 저장소는 서버가 만든 UUID 이름을 정규화한 root 아래에만 씁니다.

src/main/java/com/andongmin/studylog/web/StudyMaterialStorage.java
package com.andongmin.studylog.web;

import java.io.IOException;
import java.util.UUID;

public interface StudyMaterialStorage {
    UUID store(String safeName, byte[] content) throws IOException;
}
src/main/java/com/andongmin/studylog/web/LocalStudyMaterialStorage.java
package com.andongmin.studylog.web;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.springframework.stereotype.Component;

@Component
public class LocalStudyMaterialStorage implements StudyMaterialStorage {
    private final Path root = Path.of("var", "study-materials").toAbsolutePath().normalize();

    @Override
    public UUID store(String safeName, byte[] content) throws IOException {
        Files.createDirectories(root);
        UUID id = UUID.randomUUID();
        Path target = root.resolve(id + "-" + safeName).normalize();
        if (!target.startsWith(root)) {
            throw new IOException("invalid storage path");
        }
        Files.write(target, content);
        return id;
    }
}
src/main/java/com/andongmin/studylog/web/StudyMaterialController.java
package com.andongmin.studylog.web;

import java.io.IOException;
import java.util.Set;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/study-materials")
public class StudyMaterialController {
    private static final Set<String> ALLOWED_TYPES =
            Set.of("text/plain", "application/pdf");
    private final StudyMaterialStorage storage;

    public StudyMaterialController(StudyMaterialStorage storage) {
        this.storage = storage;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    UUID upload(@RequestParam MultipartFile file) throws IOException {
        if (file.isEmpty() || file.getSize() > 2_000_000) {
            throw new IllegalArgumentException("file size must be between 1 and 2000000 bytes");
        }
        if (!ALLOWED_TYPES.contains(file.getContentType())) {
            throw new IllegalArgumentException("unsupported content type");
        }
        String safeName = UUID.randomUUID() + ".bin";
        return storage.store(safeName, file.getBytes());
    }
}

MultipartFile.getContentType()도 클라이언트가 보낸 값이므로 파일 내용을 증명하지 않습니다. 실제 PDF 서비스라면 magic byte 확인, parser sandbox, 악성코드 검사, 격리 저장을 추가하고 검사가 끝난 파일만 다운로드 가능 상태로 전환합니다.

ch10-2의 기존 SecurityConfiguration에는 새 endpoint 권한도 추가합니다. anyRequest().denyAll()보다 앞에 있어야 합니다.

SecurityConfiguration.java — authorizeHttpRequests에 추가
.requestMatchers(HttpMethod.POST, "/api/study-materials")
    .hasAuthority("SCOPE_study.write")
src/main/resources/application.properties
spring.servlet.multipart.max-file-size=2MB
spring.servlet.multipart.max-request-size=2MB
server.max-http-request-header-size=16KB

경계값과 초과 요청의 실패 비용을 확인한다

2MB 경계 안의 작은 파일과 초과 파일을 각각 요청합니다. 서버는 별도 터미널에서 실행하고, 요청에는 쓰기 scope 토큰을 포함합니다.

터미널 1 — 서버 실행
./mvnw -q spring-boot:run
터미널 2 — 초과 파일 요청
curl -i -H "Authorization: Bearer $WRITE_TOKEN" \
  -F "file=@oversized.pdf;type=application/pdf" \
  http://localhost:8080/api/study-materials
Cost 확인 결과
2MB 초과 요청은 저장소 호출 전에 거절되고 클라이언트 파일 이름은 서버 경로로 사용되지 않아야 합니다.

실패도 싼 비용으로 끝나야 한다

초과 요청을 거부하면서 전체 파일을 메모리에 올리거나 상세 오류에 내부 경로를 포함하면 제한의 목적을 잃습니다. 413 또는 일관된 ProblemDetail로 빠르게 종료하고, 로그에는 요청 ID와 제한 종류만 남겨 공격자가 보낸 원문을 다시 저장하지 않습니다.


요청 경계를 방어했으니 다음에는 중요한 변경의 주체와 결과, 배포 이미지에 포함된 구성요소를 사후 검증할 수 있게 남깁니다.