Devin.KR

보안 리뷰 체크리스트

개발자KR 조회 5

이 장에서 배우는 것

도서관 대출 서비스에서 영수증을 조회하는 기능을 리뷰한다고 하자. 화면에 올바른 대출 번호를 넣으면 영수증이 나타나고, 테스트도 통과한다. 그러나 다른 회원의 대출 번호를 넣었을 때도 영수증이 나타난다면 기능이 정상적으로 동작한다고 볼 수 없다. 보안 리뷰는 정상 사용자가 원하는 결과를 얻는지와 함께, 허용되지 않은 요청이 어디에서 멈추는지 확인하는 일이다.

앞 장에서 공유 상태가 바뀌는 순간과 실행 순서를 살폈다면, 여기서는 신뢰할 수 없는 값이 시스템 안으로 들어오는 경계를 살핀다. 입력값의 모양, 데이터 조회 방식, 파일 경로, 요청자의 권한, 기록되는 정보, 바이트를 객체로 바꾸는 지점을 차례로 확인한다. 모든 위험을 한 번에 찾아내려 하기보다, 근거를 남길 수 있는 질문으로 리뷰를 진행한다.

  • 입력의 형식과 업무상 허용 범위를 구분하여 검사한다.
  • SQL과 파일 경로에서 입력값이 명령이나 위치를 바꾸는 지점을 찾는다.
  • 요청자의 권한을 실제 자료에 접근하기 전에 확인한다.
  • 로그와 역직렬화 경계에서 불필요한 정보와 기능을 줄인다.
  • 정상 요청의 결과를 보존하면서 보안 체크리스트의 판단 근거를 표로 남긴다.

문제 상황

팀은 회원이 자신의 대출 영수증을 확인하는 기능을 추가했다. 요청에는 대출 번호와 영수증 파일명이 들어간다. 기존 요청 객체에는 다른 화면에서 쓰던 접근 토큰과 표시 이름도 함께 들어 있다. 개발자는 문제를 추적하기 쉽도록 요청 객체 전체를 로그에 남겼다.

변경 내용을 읽어 보니 대출 번호는 SQL 문자열에 바로 붙고, 파일명은 영수증 폴더 뒤에 붙는다. 화면에서는 본인의 대출만 선택할 수 있지만 서버의 처리 메서드에는 소유자 확인이 없다. 별도 가져오기 기능은 전달받은 바이트를 Java 객체로 복원한다. 각각은 짧은 코드지만, 외부 입력에 맡긴 결정이 많다.

“화면에서 다른 회원의 대출을 고를 수 없다는 설명은 이해했다. 다만 요청 자체를 바꾸어 보내는 경우에도 서버가 같은 제한을 적용하는지 확인하고 싶다. 영수증에 접근하기 전에 대출 소유자를 검사하는 코드를 추가하자.”

이 의견의 대상은 작성자의 주의력보다 코드가 맡긴 결정이다. 누가 대출 번호를 정하는지, 누가 경로를 정하는지, 누가 접근 가능 여부를 정하는지 찾아가면 질문이 구체적이 된다. 리뷰에서는 “위험해 보인다”에서 멈추지 않고, 입력이 도착하는 곳과 거절되어야 하는 곳을 연결한다.

이 장의 실행 예제는 실제 데이터베이스나 파일 서버에 연결하지 않는다. SQL은 실행 직전의 계획으로 표현하고, 파일 경로는 계산만 한다. 네트워크와 파일 읽기 없이도 요청 처리 경계와 검토 결과를 관찰할 수 있도록 범위를 정한 것이다. 따라서 실행 성공을 실제 데이터베이스 드라이버나 운영 파일 시스템까지 검증한 결과로 확대해서는 안 된다.

입력은 값으로 받고, 명령과 위치는 제한한다

입력 검증은 형식과 의미를 함께 확인한다

입력 검증은 문자열에서 수상한 글자를 찾아 지우는 작업으로 끝나지 않는다. 대출 번호라면 null인지, 허용한 형식인지, 실제 대출이 존재하는지 확인해야 한다. 정해진 업무 형식이 있다면 허용 목록 방식으로 표현하는 편이 읽기 쉽다. 예제에서는 loan- 다음에 1부터 시작하는 최대 여섯 자리 숫자만 허용한다. 이 길이는 예제의 업무 규칙이며 보편적인 보안 기준이 아니다.

문자열을 자동으로 잘라 맞추거나 특수문자를 지우면 사용자가 보낸 값과 서버가 처리하는 값이 달라질 수 있다. 잘못된 대출 번호를 조용히 다른 번호로 바꾸기보다 요청을 거절하는 편이 문제를 추적하기 쉽다. 다만 모든 필드에 같은 정규식을 적용하지는 않는다. 사람 이름처럼 다양한 문자가 필요한 값과 내부 식별자는 허용 범위가 다르다.

크기 제한도 경계의 일부다. 파일이나 가져오기 자료라면 내부 필드뿐 아니라 전체 입력 크기와 항목 수를 제한해야 한다. 이 예제의 바이트 입력은 길이가 정해진 작은 형식만 받는다. 실제 서버에서는 바이트 배열로 모두 읽은 뒤의 검사만으로 수신 단계의 자원 사용을 막을 수 없으므로, 읽는 과정에도 한도를 둔다.

SQL에서는 문장과 값을 분리한다

SQL 주입은 입력값이 SQL 문장의 구조를 바꾸는 문제다. 문자열 양쪽에 따옴표를 붙이는 것만으로 값과 문장이 분리되지는 않는다. 리뷰에서는 쿼리를 만드는 줄까지 따라가서, 입력이 문장에 연결되는지 매개변수로 전달되는지 확인한다.

수정 후 예제는 SQL 문장에 물음표를 두고 대출 번호를 별도 목록으로 보관한다. 실제 JDBC 코드에서는 이 문장으로 PreparedStatement를 만들고 setString으로 값을 전달해야 한다. 목록을 만든 다음 다시 문자열에 붙인다면 분리의 효과가 없다. 이 장의 계획 객체는 그 전달 의도를 실행 가능한 작은 자료형으로 보여 준다.

값을 매개변수로 전달하는 방식은 테이블명이나 정렬 방향을 자유롭게 받는 문제까지 해결하지 않는다. 그런 요소를 요청으로 선택하게 한다면 서버가 정한 후보에 대응시켜야 한다. 입력 검증과 매개변수 처리는 함께 사용할 수 있으며, 어느 한쪽을 다른 쪽의 대체물로 설명하지 않는 것이 좋다.

경로에서는 기준 폴더와 최종 위치를 비교한다

경로 주입에서는 입력이 파일 내용이 아니라 접근 위치를 바꾼다. 기준 폴더에 문자열을 붙였다는 사실만으로 그 안에 머문다고 판단할 수 없다. 상위 폴더를 뜻하는 ..이나 절대 경로가 들어오면 의도한 위치를 벗어날 수 있다. 수정 후에는 절대 경로를 거절하고, 기준 폴더와 결합한 경로를 정규화한 다음 포함 관계를 확인한다.

포함 관계는 문자열의 접두어 비교가 아니라 Path의 startsWith로 확인한다. 이름이 receipts로 시작하는 다른 폴더와 실제 하위 경로는 다르기 때문이다. 예제는 추가로 영수증 파일명의 형식을 제한하여 하위 폴더 선택도 허용하지 않는다. 사용자가 파일명을 정할 필요가 없는 서비스라면 서버가 대출 정보에서 파일명을 결정하는 설계가 더 단순하다.

이 검사는 경로의 문자 구성에 대한 검사다. 실제 파일에 접근할 때는 심볼릭 링크가 다른 위치를 가리킬 수 있다. 존재하는 파일의 실제 경로 확인, 링크 처리 정책, 폴더를 수정할 수 있는 주체도 검토해야 한다. 확인 이후 파일이 바뀔 수 있는 환경까지 고려해야 하므로 정규화 한 줄만 보고 파일 접근이 안전하다고 결론 내리지 않는다.

외부 입력은 형식 검사와 권한 확인을 거쳐 값으로 전달되며 SQL 문장과 기준 폴더는 서버가 정한다

권한과 기록은 실제 처리 경계에서 확인한다

인증은 요청자가 누구인지 확인하는 일이고, 인가(authorization)는 그 요청자가 특정 일을 해도 되는지 결정하는 일이다. 로그인한 회원이라는 사실만으로 모든 대출 영수증에 접근할 수 있는 것은 아니다. 권한 검사는 로그인 여부보다 대상 자료와의 관계를 살펴야 한다.

수정 후 처리 메서드는 먼저 대출 번호를 검증하고, 그 대출의 소유자와 인증된 회원을 비교한다. 여기서 회원 정보는 요청 본문이 제공한 자기소개가 아니라 신뢰하는 인증 계층이 전달한 값이어야 한다. 예제의 Actor는 그 계약을 나타낸다. 요청의 memberId를 그대로 Actor로 옮긴다면 비교 코드가 있어도 신뢰 경계는 세워지지 않는다.

검사 위치도 중요하다. 영수증을 읽거나 반환한 뒤 검사하면 이미 정보가 노출될 수 있다. 화면, 다른 호출 경로, 내부 재사용 메서드에서 같은 기능을 호출하더라도 적용되도록 실제 처리 경계에 검사를 둔다. 더 큰 서비스에서는 권한 정책을 공통화할 수 있지만, 공통화했다는 설명과 실제 호출 여부는 별도로 확인해야 한다.

존재하지 않는 대출과 다른 회원의 대출은 예제에서 모두 DENIED로 표현한다. 외부에 전달하는 실패 결과를 같게 하여 대출의 존재 여부가 직접 드러나는 것을 줄이려는 선택이다. 운영 내부에서는 원인을 구분할 필요가 있을 수 있다. 그때도 외부 응답과 내부 기록의 목적을 나누어 설계한다.

로그에서는 “나중에 필요할지 모르니 전부 남긴다”는 선택을 경계한다. 요청 객체의 문자열 표현에는 접근 토큰, 이름, 이메일처럼 현재 기능의 추적에 필요하지 않은 값이 포함될 수 있다. 특히 record의 기본 문자열 표현은 구성 요소를 보여 주므로 요청 전체를 기록하는 코드에서 필드를 하나씩 확인해야 한다.

예제는 성공한 작업의 종류만 기록한다. 이것은 어떤 로그에도 식별자를 넣지 말라는 규칙이 아니다. 운영 목적에 따라 제한된 식별자를 남길 수 있지만, 보관 기간과 접근 권한까지 함께 정해야 한다. 토큰의 일부를 가렸더라도 남은 값이 인증에 쓰이는지, 다른 기록과 결합해 개인을 알아낼 수 있는지 검토한다.

“요청 전체를 기록하면 접근 토큰과 표시 이름도 함께 남는다. 이 처리의 성공 여부를 확인하는 데 필요한 필드는 작업 종류다. 우선 해당 값만 기록하고, 추가 식별자가 필요한지는 운영 목적을 정한 뒤 결정하자.”

직렬화 경계와 체크리스트의 증거를 구분한다

직렬화(serialization)는 데이터를 저장하거나 전달할 수 있는 형태로 바꾸는 일이다. 역방향으로 자료를 복원할 때는 무엇을 만들 수 있게 허용하는지 확인해야 한다. 특히 신뢰할 수 없는 바이트를 ObjectInputStream의 readObject에 넘기면, 단순히 숫자와 문자열을 읽는 것보다 넓은 객체 복원 기능을 사용하게 된다.

복원 결과를 원하는 타입으로 검사하더라도 그 검사는 객체를 읽은 뒤에 실행된다. 따라서 마지막 형변환만 확인해서는 충분하지 않다. 기존 형식을 유지해야 한다면 직렬화 필터로 허용 클래스와 깊이, 참조 수, 크기 등의 한계를 정하는 방안을 검토한다. 필터만 추가하고 실제로 어떤 객체가 필요한지 모르는 상태를 유지해서는 판단 근거가 약하다.

이 예제의 가져오기 자료는 버전과 대출 순번 두 정수면 충분하다. 수정 후에는 정확히 여덟 바이트를 받고, 버전과 값의 범위를 확인한다. 객체 그래프를 복원할 이유가 없으므로 해당 기능을 제거한 것이다. 실제 형식을 바꿀 때는 기존 자료와의 호환 계획도 필요하지만, 여기서는 작은 새 형식의 경계만 실행한다.

체크리스트는 검사를 했다는 표시보다 판단의 근거를 담아야 한다. “SQL 확인 완료” 대신 “문장은 고정되고 값은 별도 매개변수로 전달된다”라고 남기면 다음 리뷰어가 검증할 위치를 알 수 있다. 반대로 “테스트가 통과했다”만으로 모든 입력과 배포 환경이 안전하다고 말하지 않는다.

요청 처리 코드에서 확인할 여섯 가지 질문
항목리뷰 질문확인할 근거
입력 검증형식과 크기를 제한하는가허용 형식, 길이, 잘못된 값의 거절
SQL값이 문장 구조를 바꾸는가고정 문장과 매개변수 전달
경로기준 폴더를 벗어날 수 있는가절대 경로 거절과 경로 포함 검사
권한자료 접근 전에 소유자를 확인하는가신뢰하는 요청자 정보와 대상 비교
로그비밀값과 개인정보가 필요 이상 남는가기록 필드와 운영 목적
직렬화입력이 어떤 객체와 크기를 만들 수 있는가복원 기능의 범위와 입력 한도

완성 코드는 같은 요청 처리 예제를 두 방식으로 구현한다. 정상 요청의 결과를 비교하고, 다른 회원의 요청과 잘못된 입력이 거절되는지도 실행한다. 마지막 표는 사람이 코드를 읽어 작성한 검토 기록을 출력한다. 소스 문자열에서 특정 단어를 찾는 자동 보안 검사기가 아니며, 표의 판단과 실행 검증은 서로 다른 증거다.

보안 검토 결과는 코드에서 확인한 근거와 실행으로 확인한 사례를 함께 기록하되 둘을 구분한다

완성 코드

다음 내용을 Main.java로 저장한다. 파일 쓰기와 네트워크 접근은 없다. 수정 전 코드는 위험 지점을 비교하기 위해 포함했으며, SQL 실행이나 외부 바이트의 객체 복원은 호출하지 않는다. 정상 처리에서 계산한 경로 역시 실제 파일 접근에 사용하지 않는다.

Main.java

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.nio.ByteBuffer;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;

public class Main {
    record Actor(String memberId) {}

    record Request(
            String loanId,
            String fileName,
            String accessToken,
            String displayName) {}

    record SqlPlan(String sql, List<String> parameters) {}

    record Review(
            String item,
            String before,
            String after,
            String evidence) {}

    static final Map<String, String> OWNERS = Map.of(
            "loan-1", "member-1",
            "loan-2", "member-2");

    static final Path ROOT =
            Path.of("receipts").toAbsolutePath().normalize();

    static int failures;

    static final class Before {
        static SqlPlan plan(String loanId) {
            return new SqlPlan(
                    "SELECT receipt FROM loans WHERE loan_id = '"
                            + loanId + "'",
                    List.of());
        }

        static Path path(String fileName) {
            return ROOT.resolve(fileName);
        }

        static String log(Request request) {
            return request.toString();
        }

        static Object decode(byte[] bytes)
                throws IOException, ClassNotFoundException {
            try (ObjectInputStream input = new ObjectInputStream(
                    new ByteArrayInputStream(bytes))) {
                return input.readObject();
            }
        }

        static String handle(Actor actor, Request request) {
            plan(request.loanId());
            path(request.fileName());
            log(request);
            return "receipt:" + request.loanId();
        }
    }

    static final class After {
        static void validateLoanId(String loanId) {
            if (loanId == null
                    || !loanId.matches("loan-[1-9][0-9]{0,5}")) {
                throw new IllegalArgumentException("INVALID_LOAN");
            }
        }

        static SqlPlan plan(String loanId) {
            validateLoanId(loanId);
            return new SqlPlan(
                    "SELECT receipt FROM loans WHERE loan_id = ?",
                    List.of(loanId));
        }

        static Path path(String fileName) {
            if (fileName == null) {
                throw new IllegalArgumentException("INVALID_PATH");
            }

            Path supplied = Path.of(fileName);
            if (supplied.isAbsolute()) {
                throw new IllegalArgumentException("INVALID_PATH");
            }

            Path target = ROOT.resolve(supplied).normalize();
            if (!target.startsWith(ROOT)
                    || !fileName.matches(
                            "receipt-[1-9][0-9]{0,5}\\.txt")) {
                throw new IllegalArgumentException("INVALID_PATH");
            }
            return target;
        }

        static void authorize(Actor actor, String loanId) {
            String owner = OWNERS.get(loanId);
            if (actor == null || owner == null
                    || !owner.equals(actor.memberId())) {
                throw new SecurityException("DENIED");
            }
        }

        static String log() {
            return "event=receipt_view";
        }

        static int decode(byte[] bytes) {
            if (bytes == null || bytes.length != 8) {
                throw new IllegalArgumentException("INVALID_DATA");
            }

            ByteBuffer input = ByteBuffer.wrap(bytes);
            int version = input.getInt();
            int number = input.getInt();
            if (version != 1 || number < 1 || number > 999999) {
                throw new IllegalArgumentException("INVALID_DATA");
            }
            return number;
        }

        static String handle(Actor actor, Request request) {
            if (request == null) {
                throw new IllegalArgumentException("INVALID_REQUEST");
            }
            validateLoanId(request.loanId());
            authorize(actor, request.loanId());
            plan(request.loanId());
            path(request.fileName());
            log();
            return "receipt:" + request.loanId();
        }
    }

    static Request request(String loanId, String fileName) {
        return new Request(
                loanId, fileName, "sample-token", "샘플 회원");
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
        if (!condition) {
            failures++;
        }
    }

    static boolean rejects(
            Class<? extends RuntimeException> expected,
            Runnable action) {
        try {
            action.run();
            return false;
        } catch (RuntimeException exception) {
            return expected.isInstance(exception);
        }
    }

    static void printReview() {
        List<Review> reviews = List.of(
                new Review("입력", "수정 필요", "확인",
                        "대출 번호 형식과 바이트 길이 제한"),
                new Review("SQL", "수정 필요", "확인",
                        "고정 문장과 매개변수 분리"),
                new Review("경로", "수정 필요", "확인",
                        "절대 경로 거절과 포함 검사"),
                new Review("권한", "수정 필요", "확인",
                        "처리 전에 대출 소유자 확인"),
                new Review("로그", "수정 필요", "확인",
                        "토큰과 표시 이름 기록 제외"),
                new Review("직렬화", "수정 필요", "확인",
                        "객체 복원 대신 고정 형식 해석"));

        System.out.println("항목 | 수정 전 | 수정 후 | 검토 근거");
        for (Review review : reviews) {
            System.out.println(
                    review.item() + " | " + review.before()
                            + " | " + review.after()
                            + " | " + review.evidence());
        }
    }

    public static void main(String[] args) {
        Actor actor = new Actor("member-1");
        Request normal = request("loan-1", "receipt-1.txt");

        String before = Before.handle(actor, normal);
        String after = After.handle(actor, normal);
        System.out.println("수정 전 정상 응답: " + before);
        System.out.println("수정 후 정상 응답: " + after);
        check(before.equals(after), "정상 응답 유지");

        Request other = request("loan-2", "receipt-2.txt");
        check(Before.handle(actor, other).equals("receipt:loan-2"),
                "수정 전 권한 누락 재현");
        check(rejects(SecurityException.class,
                () -> After.handle(actor, other)),
                "다른 회원의 대출 거절");
        check(rejects(SecurityException.class,
                () -> After.handle(
                        actor, request("loan-3", "receipt-3.txt"))),
                "없는 대출 거절");

        check(rejects(IllegalArgumentException.class,
                () -> After.handle(
                        actor, request("loan-1' OR '1'='1",
                                "receipt-1.txt"))),
                "잘못된 대출 번호 거절");
        check(rejects(IllegalArgumentException.class,
                () -> After.handle(
                        actor, request(null, "receipt-1.txt"))),
                "null 대출 번호 거절");

        SqlPlan plan = After.plan("loan-1");
        check(plan.sql().equals(
                "SELECT receipt FROM loans WHERE loan_id = ?")
                && plan.parameters().equals(List.of("loan-1")),
                "SQL 문장과 값 분리");

        check(rejects(IllegalArgumentException.class,
                () -> After.handle(
                        actor, request("loan-1", "../private.txt"))),
                "상위 경로 거절");
        check(rejects(IllegalArgumentException.class,
                () -> After.path(
                        ROOT.resolve("receipt-1.txt").toString())),
                "절대 경로 거절");
        check(After.path("receipt-1.txt").getParent().equals(ROOT),
                "정상 경로 유지");

        check(Before.log(normal).contains(normal.accessToken())
                && Before.log(normal).contains(normal.displayName()),
                "수정 전 로그 노출 재현");
        check(After.log().equals("event=receipt_view"),
                "필요한 로그만 기록");

        byte[] valid = ByteBuffer.allocate(8)
                .putInt(1).putInt(1).array();
        byte[] badVersion = ByteBuffer.allocate(8)
                .putInt(2).putInt(1).array();
        byte[] badNumber = ByteBuffer.allocate(8)
                .putInt(1).putInt(0).array();

        check(After.decode(valid) == 1, "고정 형식 읽기");
        check(rejects(IllegalArgumentException.class,
                () -> After.decode(new byte[9])),
                "추가 바이트 거절");
        check(rejects(IllegalArgumentException.class,
                () -> After.decode(badVersion)),
                "알 수 없는 버전 거절");
        check(rejects(IllegalArgumentException.class,
                () -> After.decode(badNumber)),
                "허용 범위 밖 번호 거절");

        System.out.println("실패 수: " + failures);
        printReview();

        if (failures != 0) {
            throw new AssertionError("검증 실패");
        }
    }
}

줄별 해설

Actor와 Request를 나눈 부분은 신뢰 출처를 드러낸다. Request의 모든 구성 요소는 외부에서 왔다고 보고, Actor는 인증 계층에서 받았다고 가정한다. 자료형을 나누었다고 인증이 구현되는 것은 아니다. 실제 연결 코드가 요청 본문으로 Actor를 만들지 않는지까지 확인해야 한다.

OWNERS와 ROOT를 선언한 부분은 예제의 서버 측 기준이다. 대출 소유자는 요청에서 받지 않으며, 기준 폴더도 서버가 정한다. 실행 위치에 따라 ROOT의 절대 경로는 달라지지만 이를 출력하지 않으므로 예상 출력은 달라지지 않는다. 폴더 생성이나 파일 쓰기도 발생하지 않는다.

Before의 plan, path, log, decode는 각각 입력이 SQL 문장, 파일 위치, 로그 내용, 객체 복원 기능으로 이어지는 지점이다. handle은 전달받은 actor를 사용하지 않는다. 메서드 인자로 요청자가 존재하는 것과 그 요청자의 권한을 확인하는 것은 별개라는 사실을 보여 준다.

validateLoanId의 조건은 null 검사와 형식 검사를 순서대로 수행한다. matches는 문자열 전체를 비교한다. 다만 형식에 맞는 loan-3도 이 예제에는 존재하지 않는다. 형식 검사는 값의 모양을 제한하고, 소유자 조회는 실제 대상과 접근 가능 여부를 확인한다.

After.plan의 물음표와 목록은 문장과 값의 분리를 표현한다. 실행 검증은 두 요소가 기대한 모양인지 확인한다. 실제 SQL이 데이터베이스에서 올바르게 실행되는지는 이 프로그램의 검증 범위가 아니다. 저장소 구현을 연결할 때는 이 계획을 매개변수로 전달하는지 추가로 리뷰해야 한다.

After.path의 세 단계는 절대 경로 거절, 정규화한 경로의 포함 검사, 파일명 제한이다. 일반 파일명 하나만 받는 정책을 코드에 드러냈다. Path.of는 잘못된 경로 문자열에 대해 예외를 낼 수 있으므로, 실제 요청 처리 계층에서는 이런 실패도 정해진 오류 응답으로 변환해야 한다.

authorize는 부수 효과보다 먼저 호출된다. 예제의 plan과 path는 계산만 하지만, 실제 서비스에서는 그 뒤에 조회와 파일 읽기가 이어질 수 있다. 권한 검사를 앞에 배치한 의도를 유지해야 한다. 대출 자료를 읽은 다음 응답 직전에만 확인하는 방식으로 옮기지 않는다.

log는 요청 객체를 받지 않는다. 필요한 값이 작업 종류뿐이므로 비밀값을 전달할 이유가 없다. 호출자가 다른 요청을 넘겨도 로그 내용은 같다는 것을 테스트한다. 수정 전 노출 재현 검사는 토큰을 화면에 출력하지 않고 포함 여부만 확인한다.

decode는 길이, 버전, 값 범위를 확인한다. ByteBuffer의 기본 바이트 순서를 송수신 형식의 약속으로 사용한다. 버전이 다르거나 뒤에 바이트가 추가되면 거절한다. 형식의 버전만 검사하고 전체 길이를 열어 두면, 나중에 구현마다 남은 바이트를 다르게 처리할 여지가 생긴다.

check와 rejects는 검증 결과를 모은다. 기대한 종류의 예외가 발생한 경우만 거절 성공으로 판단한다. 예를 들어 권한 거절을 기대한 검사에서 다른 예외가 발생하면 통과하지 않는다. 다만 여기서는 예외 메시지와 외부 응답 변환까지 검사하지 않는다.

printReview의 기록은 수동 검토 결과다. “확인”은 이 예제에서 설명한 기준으로 읽었다는 뜻이다. 코드가 바뀌었는데 이 목록을 갱신하지 않으면 오래된 판단이 남을 수 있다. 실행 검증은 그런 불일치 일부를 발견하는 보조 수단이며, 표를 자동으로 참으로 만드는 장치는 아니다.

실행 결과

명령과 예상 출력은 다음과 같다. 아래 출력은 코드의 호출 순서와 문자열에 맞춘 예상값이며, 이 원고 작성 과정에서 실행 도구로 확인한 결과는 아니다. 독자는 실행 후 정상 응답 두 줄과 각 PASS를 함께 확인한다. 수정 전후가 같은 결과를 내야 하는 범위는 정상 요청이며, 허용하지 않는 요청은 수정 후 거절되는 것이 목표다.

javac -d out *.java
java -cp out Main
수정 전 정상 응답: receipt:loan-1
수정 후 정상 응답: receipt:loan-1
PASS 정상 응답 유지
PASS 수정 전 권한 누락 재현
PASS 다른 회원의 대출 거절
PASS 없는 대출 거절
PASS 잘못된 대출 번호 거절
PASS null 대출 번호 거절
PASS SQL 문장과 값 분리
PASS 상위 경로 거절
PASS 절대 경로 거절
PASS 정상 경로 유지
PASS 수정 전 로그 노출 재현
PASS 필요한 로그만 기록
PASS 고정 형식 읽기
PASS 추가 바이트 거절
PASS 알 수 없는 버전 거절
PASS 허용 범위 밖 번호 거절
실패 수: 0
항목 | 수정 전 | 수정 후 | 검토 근거
입력 | 수정 필요 | 확인 | 대출 번호 형식과 바이트 길이 제한
SQL | 수정 필요 | 확인 | 고정 문장과 매개변수 분리
경로 | 수정 필요 | 확인 | 절대 경로 거절과 포함 검사
권한 | 수정 필요 | 확인 | 처리 전에 대출 소유자 확인
로그 | 수정 필요 | 확인 | 토큰과 표시 이름 기록 제외
직렬화 | 수정 필요 | 확인 | 객체 복원 대신 고정 형식 해석

“수정 전 권한 누락 재현”의 PASS는 이전 코드가 바람직하다는 뜻이 아니다. 알려진 결함이 실제로 나타나는지 확인했다는 뜻이다. 이런 재현 검사가 있으면 개선 코드가 무엇을 막는지 비교하기 쉽다. 정상 동작 보존 검사와 결함 재현 검사의 이름을 구분해야 결과를 오해하지 않는다.

실무에서 자주 틀리는 것

다음 네 예제는 각각 독립적으로 실행할 수 있다. 비교할 예제를 Main.java에 저장하고 같은 명령으로 실행한다. 기존 파일의 내용을 교체하는 방식으로 사용하며, 같은 이름의 Main을 한 폴더에 여러 개 두지 않는다.

입력 형식 검사만으로 SQL 처리가 끝났다고 생각한다

틀린 방식은 검증 규칙이 앞으로도 변하지 않을 것이라고 기대하며 값을 문장에 붙인다. 고친 방식은 허용 형식 검사와 별개로 문장과 값을 분리한다. 다음 코드는 정상 입력의 표현을 비교한다. 출력 두 줄은 각각 SELECT loan-1과 SELECT ? / loan-1이다. 결과 표현이 달라지는 이유는 실행할 문장과 값의 경계를 드러냈기 때문이다.

public class Main {
    static String before(String id) {
        return "SELECT " + id;
    }

    static String after(String id) {
        String statement = "SELECT ?";
        return statement + " / " + id;
    }

    public static void main(String[] args) {
        System.out.println(before("loan-1"));
        System.out.println(after("loan-1"));
    }
}

이 코드는 분리된 표현만 보여 준다. 실제 JDBC 처리에서는 물음표가 든 문장과 값을 PreparedStatement에 전달해야 한다. 표시용 문자열을 실제 SQL로 실행하는 구현으로 바꾸어서는 안 된다. 정상 응답의 동일성은 완성 코드의 handle 비교가 담당하고, 여기서는 내부 구성 방식의 차이를 관찰한다.

경로를 문자열 접두어로 검사한다

기준 이름이 비슷한 이웃 폴더는 하위 폴더가 아니다. 다음 예제는 정상 경로에서 두 방식 모두 true인지 검사한 뒤, 이웃 폴더에 대한 차이를 검사한다. 두 check의 예상 출력은 모두 PASS다. 잘못된 방식은 이웃 폴더도 포함된 것으로 판단하며, 고친 방식은 경로 요소의 관계로 판단한다.

import java.nio.file.Path;

public class Main {
    static boolean before(Path root, Path target) {
        return target.toString().startsWith(root.toString());
    }

    static boolean after(Path root, Path target) {
        return target.startsWith(root);
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
    }

    public static void main(String[] args) {
        Path root = Path.of("receipts").toAbsolutePath().normalize();
        Path normal = root.resolve("receipt-1.txt");
        Path neighbor = root.resolveSibling(
                root.getFileName() + "-old").resolve("receipt-1.txt");

        check(before(root, normal) && after(root, normal),
                "정상 경로 결과 유지");
        check(before(root, neighbor) && !after(root, neighbor),
                "이웃 폴더 구분");
    }
}

이 수정도 심볼릭 링크까지 검사하지는 않는다. 코드 리뷰 의견에는 “문자열 접두어 오판을 고친다”처럼 해결한 범위를 적는다. 해결 범위를 작고 정확하게 쓰면 남은 파일 접근 정책을 다른 사람이 이어서 검토할 수 있다.

요청에 적힌 회원 번호를 권한의 근거로 삼는다

요청자가 자신의 회원 번호를 보내게 한 뒤 소유자와 비교하면 그럴듯한 권한 검사처럼 보인다. 그러나 공격자는 소유자의 회원 번호를 넣을 수 있다. 아래 예제는 정상 요청의 결과가 유지되고, 위조 요청은 수정 후 거절되는지 확인한다. 예상 출력은 두 줄 모두 PASS다.

public class Main {
    static boolean before(String claimedMember, String owner) {
        return owner.equals(claimedMember);
    }

    static boolean after(String authenticatedMember, String owner) {
        return owner.equals(authenticatedMember);
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
    }

    public static void main(String[] args) {
        check(before("member-1", "member-1")
                        == after("member-1", "member-1"),
                "정상 권한 결과 유지");
        check(before("member-2", "member-2")
                        && !after("member-1", "member-2"),
                "요청의 회원 번호 위조 거절");
    }
}

수정 후 매개변수 이름만 바꾸어서는 부족하다. authenticatedMember를 어디서 받는지 호출부까지 읽어야 한다. 보안 리뷰에서는 비교 연산의 정확성과 비교 대상 값의 출처를 함께 확인한다.

객체 전체를 남긴 뒤 비밀값을 지우려 한다

요청 전체를 문자열로 만든 다음 토큰을 치환하는 방식은 새 필드가 추가될 때 빠뜨리기 쉽다. 표시 이름 같은 개인정보도 그대로 남을 수 있다. 필요한 필드만 선택하면 요청 자료형의 변경이 로그에 바로 번지지 않는다. 다음 예제는 성공한 작업의 기록이 유지되는지, 불필요한 필드가 제외되는지 확인하며 두 줄 모두 PASS를 출력한다.

public class Main {
    record Request(String token, String displayName) {}

    static String before(Request request) {
        return "event=receipt_view " + request;
    }

    static String after() {
        return "event=receipt_view";
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
    }

    public static void main(String[] args) {
        Request request = new Request("sample-token", "샘플 회원");
        check(before(request).startsWith(after()),
                "작업 종류 기록 유지");
        check(!after().contains(request.token())
                        && !after().contains(request.displayName()),
                "불필요한 필드 제외");
    }
}

로그 문자열 자체가 전후에 같아야 한다고 검사하지 않는다. 이번 수정의 목적이 기록에서 불필요한 정보를 제거하는 것이기 때문이다. 보존할 기능은 작업 종류의 기록이고, 바뀌어야 할 부분은 요청 전체의 노출이다.

한눈에 보기

보안 리뷰에서 남길 판단과 적용 범위
경계이 예제의 조치추가로 확인할 것
요청 입력대출 번호 형식과 크기 제한실제 수신 과정의 크기 제한
SQL 실행고정 문장과 값 분리JDBC의 매개변수 전달 구현
파일 접근경로 정규화와 포함 검사링크, 폴더 권한, 실제 파일의 소속
자료 권한처리 전에 소유자 확인인증 정보 출처와 모든 호출 경로
로그 저장작업 종류만 선택하여 기록보관 기간과 열람 권한
자료 복원길이가 정해진 두 정수 해석버전 변경과 기존 자료 호환

체크리스트의 항목 수보다 중요한 것은 코드에서 근거를 가리킬 수 있는가다. 정상 요청 하나가 성공했다는 사실, 특정 잘못된 입력이 거절되었다는 사실, 사람이 구현을 읽고 확인한 사실을 구분해 남긴다. 그래야 후속 변경에서 무엇을 다시 검토해야 하는지 드러난다.

연습 문제

  1. 대출 번호 형식에 맞는 loan-3을 요청하면 완성 코드가 어느 단계에서 거절하는지 설명하라. 형식 검사만으로 이 요청을 처리하면 어떤 판단이 빠지는지도 적어 보라.
  2. 영수증 폴더 아래에 다른 위치를 가리키는 심볼릭 링크가 있다고 하자. normalize와 startsWith만으로 충분한지 설명하고, 실제 파일 읽기를 추가할 때 필요한 검토 질문 두 가지를 작성하라.
  3. 가져오기 자료에 정수 필드 하나를 추가해야 한다. 기존 여덟 바이트 형식과 새 형식을 어떻게 구분할지 정하고, 길이·버전·값 범위에 대한 검증 사례를 각각 제시하라.
  4. 운영 담당자가 실패한 영수증 조회를 추적하기 위해 로그에 토큰 전체를 넣자고 제안했다. 필요한 추적 정보와 제외할 정보를 나누고, 근거와 대안을 포함한 리뷰 의견을 작성하라.

정답과 해설

  1. loan-3은 허용 형식에 맞으므로 validateLoanId를 통과한다. authorize에서 소유자를 찾지 못해 DENIED로 거절된다. 형식 검사는 실제 대출의 존재와 요청자의 접근 권한을 보장하지 않는다. 존재하지 않는 대출과 다른 회원의 대출에 같은 외부 실패 결과를 사용한 점도 설명할 수 있다.
  2. 두 메서드는 경로 요소를 계산하고 비교하므로 실제 링크가 가리키는 위치까지 보장하지 않는다. “접근할 파일의 실제 위치가 허용 영역 안인가”, “누가 기준 폴더와 링크를 바꿀 수 있는가”를 질문할 수 있다. 확인과 읽기 사이의 변경 가능성도 검토 대상이다. 실제 경로 확인 한 번을 모든 상황의 해결책으로 적지 않는다.
  3. 새 버전을 따로 정하고 버전에 따라 정확한 길이를 요구하는 방식이 가능하다. 기존 버전은 여덟 바이트, 새 버전은 열두 바이트로 약속할 수 있다. 기존 버전에 추가 바이트가 붙은 입력, 새 버전인데 길이가 짧은 입력, 알 수 없는 버전, 새 필드의 범위를 벗어난 값을 각각 거절하는지 검사한다. 새 필드의 의미에 맞는 허용 범위도 명시해야 한다.
  4. “실패 요청을 연결해 보고 싶다는 목적은 이해했다. 토큰 전체를 기록하면 로그를 읽을 수 있는 사람에게 인증 정보도 전달된다. 실패 종류와 별도 추적 식별자로 연결하고, 회원 식별자가 필요한지는 접근 권한과 보관 기간을 정한 뒤 추가하자”라고 쓸 수 있다. 추적 식별자는 토큰에서 복사하지 않고 별도로 관리해야 하며, 요청자가 임의로 넣은 값이라면 길이와 개행 처리도 확인한다.

보안 리뷰는 입력이 맡아도 되는 결정과 서버가 책임져야 하는 결정을 나누는 작업이다. 요청의 값은 필요한 범위 안에서 받고, 권한은 실제 대상에 연결하며, 기록과 복원 기능은 목적에 맞게 제한한다. 이 근거를 남긴 뒤 다음 장에서는 성능에 관한 주장을 측정으로 확인하는 방법을 다룬다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.