클라우드 Mac에서 iOS Base64URL 엄격 디코딩 검증하기

클라우드 Mac에서 iOS Base64URL 엄격 디코딩 검증하기

클라우드 Mac에서 iOS Base64URL 엄격 디코딩 검증하기

API가 바이너리 커서를 Base64URL로 인코딩해 전달하는데, iOS 클라이언트가 간혹 손상된 커서를 유효한 데이터로 받아들이는 경우가 있습니다. 원인이 반드시 네트워크에 있는 것은 아닙니다. 느슨한 디코딩, 자동 패딩 추가, 잘못된 문자 무시 같은 동작은 거부해야 할 입력마저 바이트로 변환할 수 있습니다. 클라우드 Mac에서는 먼저 독립적인 Swift 스크립트로 디코딩 규약을 확정한 뒤, 같은 테스트 데이터를 앱의 테스트 타깃으로 옮길 수 있습니다. 시뮬레이터를 실행하지 않고도 인코딩 경계 조건을 확인할 수 있는 방법입니다.

허용할 인코딩부터 명확히 정하기

이 글에서는 비어 있지 않고 패딩이 없는 Base64URL만 허용합니다. 사용할 수 있는 문자는 영문자, 숫자, -, _이며, 공백, 줄바꿈, +, /, =는 허용하지 않습니다. 이는 이 앱 API에서 명시적으로 선택한 규칙이지, Base64를 사용하는 모든 프로토콜에 똑같이 적용해야 하는 규칙은 아닙니다.

하나의 진입점에서 입력 형식을 추측해 표준 Base64와 Base64URL을 모두 처리해서는 안 됩니다. 기존 API에서 패딩이 있는 형식을 요구한다면 별도의 파싱 진입점을 제공하고 각각 테스트해야 합니다. 그래야 클라이언트 업데이트 후 허용되는 입력 범위가 눈에 띄지 않게 넓어지는 일을 막을 수 있습니다.

길이에도 구조적 제약이 있습니다. 패딩 없는 문자열의 문자 수를 4로 나눈 나머지는 0, 2, 3 중 하나여야 합니다. 나머지가 1이면 유효한 인코딩을 구성할 수 없습니다. 예제에서는 입력 상한을 UTF-8 기준 65536바이트로 설정합니다. 이는 테스트용 애플리케이션 정책이며, 프로토콜에서 정한 값은 아닙니다.

디코딩은 바이트를 복원할 뿐, 내용의 신뢰성을 보장하지 않습니다. 토큰, 커서, 서명 매개변수에 사용한다면 구조, 출처, 업무 로직상 유효성을 별도로 검증해야 합니다.

재인코딩으로 정규 표현 강제하기

다음 코드를 base64url-check.swift로 저장합니다. 입력 크기를 먼저 제한하고, 허용 문자 집합을 검사한 다음, 내부 처리 과정에서만 패딩을 추가합니다. 알 수 없는 문자를 무시하는 옵션을 사용하거나 공백 문자를 미리 제거해서는 안 됩니다.

import Foundation

enum DecodeFailure: Error {
    case size
    case alphabet
    case length
    case payload
    case noncanonical
}

func decodeBase64URL(
    _ text: String,
    maxEncodedBytes: Int = 65536
) throws -> Data {
    let count = text.utf8.count
    guard count > 0, count <= maxEncodedBytes else {
        throw DecodeFailure.size
    }

    let valid = text.utf8.allSatisfy { byte in
        (65...90).contains(byte)
        || (97...122).contains(byte)
        || (48...57).contains(byte)
        || byte == 45
        || byte == 95
    }
    guard valid else {
        throw DecodeFailure.alphabet
    }

    let remainder = count % 4
    guard remainder != 1 else {
        throw DecodeFailure.length
    }

    var padded = text
        .replacingOccurrences(of: "-", with: "+")
        .replacingOccurrences(of: "_", with: "/")
    padded += String(repeating: "=", count: (4 - remainder) % 4)

    guard let data = Data(base64Encoded: padded) else {
        throw DecodeFailure.payload
    }

    let canonical = data.base64EncodedString()
        .replacingOccurrences(of: "+", with: "-")
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: "=", with: "")

    guard canonical == text else {
        throw DecodeFailure.noncanonical
    }
    return data
}

마지막 비교는 생략하면 안 됩니다. 단일 바이트 FF의 정규 표현은 _w입니다. _x는 사용하지 않는 끝부분 비트가 0이 아닌데도 일부 디코더에서는 같은 바이트로 복원될 수 있습니다. 재인코딩한 결과를 비교하면 하위 디코더가 얼마나 느슨하게 동작하는지와 관계없이 입력 허용 여부를 결정할 수 있습니다.

이 함수는 문자열로 바로 변환하지 않고 Data를 반환합니다. 유효한 바이너리 데이터라고 해서 반드시 UTF-8인 것은 아닙니다. 텍스트가 필요한 상위 계층에서는 문자 인코딩을 별도로 검사해야 하며, 대체 문자로 손상된 내용을 감춰서는 안 됩니다.

실패해야 할 입력을 실행 가능한 검증 코드로 만들기

다음 테스트를 같은 파일 끝에 추가합니다. 테스트가 실패하면 오류를 던져 스크립트가 0이 아닌 종료 코드로 끝나게 합니다. 최적화 빌드에서 제거될 수 있는 어설션에는 의존하지 않습니다.

enum CheckFailure: Error {
    case mismatch
    case acceptedInvalid
}

let validCases: [(String, [UInt8])] = [
    ("AA", [0]),
    ("_w", [255]),
    ("-_8", [251, 255]),
    ("Zm9v", [102, 111, 111])
]

for (text, expected) in validCases {
    guard try decodeBase64URL(text) == Data(expected) else {
        throw CheckFailure.mismatch
    }
}

let invalidCases = [
    "", "A", "Zg==", "Zg\n", "+w", "/w", "_x", "中文"
]

for text in invalidCases {
    do {
        _ = try decodeBase64URL(text)
    } catch is DecodeFailure {
        continue
    }
    throw CheckFailure.acceptedInvalid
}

do {
    _ = try decodeBase64URL("AAAA", maxEncodedBytes: 3)
} catch is DecodeFailure {
    exit(0)
}
throw CheckFailure.acceptedInvalid

명령줄 도구가 구성된 클라우드 Mac에서 실행합니다.

xcrun swift base64url-check.swift

성공하면 출력 없이 종료 코드 0으로 끝납니다. _x는 끝부분 비트 검사를 확인하는 입력입니다. 줄바꿈이 포함된 입력은 나중에 누군가 자동 정리 로직을 추가하는 일을 방지합니다. 중국어 입력은 문자가 “영문자처럼 보이는지”가 아니라 ASCII 바이트를 기준으로 허용 문자 집합을 검사하는지 확인합니다.

크기 경계와 호출 위치 점검하기

입력 상한 검사는 문자열 치환과 디코딩보다 먼저 수행해야 합니다. 그렇지 않으면 너무 긴 입력이 이미 불필요한 복사본을 만들어 낸 뒤에야 차단됩니다. 여기서 허용하는 최대 길이의 정규 입력은 디코딩하면 49152바이트가 됩니다. 다만 이는 인코딩 계층의 크기 예산일 뿐이며, 실제 업무 필드에는 일반적으로 더 작은 상한을 설정해야 합니다.

함수에 전달된 문자열은 이미 메모리에 존재하므로, 이 함수가 네트워크 응답 크기 제한을 대신할 수는 없습니다. 수신 계층은 응답 본문 크기를, 디코딩 계층은 필드 크기를, 구조 파싱 계층은 컬렉션의 항목 수와 중첩 깊이를 각각 제한해야 합니다.

다음 세 가지 경계 테스트도 추가해야 합니다.

디코딩 결과를 식별자로 사용하면서 다른 경로에서는 느슨한 형식까지 허용하는 일이 없도록 해야 합니다. 캐시 키, 중복 제거 키, 서명 입력에 어떤 표현을 사용할지는 API 규약에서 일관되게 정해야 합니다. 그렇지 않으면 같은 내용이 서로 다르게 처리될 수 있습니다.

회귀 테스트에 통합하고 진단 정보의 범위 제한하기

독립 스크립트가 통과하면 디코딩 함수를 공유 모듈로 옮기고, 테스트 데이터는 앱의 단위 테스트 타깃으로 이전합니다. 명령줄 스크립트는 빠른 재현에 적합하고, 앱 타깃 테스트는 실제 의존성과 빌드 설정의 차이를 찾는 역할을 합니다. 두 곳에서 별도의 구현을 장기간 유지해서는 안 됩니다.

고정된 테스트 데이터 외에도 다양한 길이의 바이트 배열에 대해 “인코딩, 디코딩, 바이트별 비교”를 수행할 수 있습니다. 특히 길이를 3으로 나눴을 때 나머지가 달라지는 경우를 골고루 확인해야 합니다. 이 나머지가 마지막 인코딩 그룹의 끝부분 비트 사용량을 결정하기 때문입니다. 이어서 마지막 문자를 바꿔 비정규 표현이 검사를 통과하지 못하는지도 확인합니다.

실패 보고서에는 오류 유형, 입력 길이, 테스트 사례 번호만 남기고 실제 토큰이나 전체 커서는 기록하지 않습니다. 배포 전에는 자동으로 앞뒤 문자를 잘라 내는 처리가 없는지, 알 수 없는 문자를 무시하지 않는지, 서로 다른 문자 집합을 혼용하지 않는지, 구조 검증이 디코딩 이후에 수행되는지 확인합니다. 최종 목표는 더 많은 입력을 디코딩하는 것이 아니라, 양측이 합의한 단 하나의 표현만 API가 받아들이게 하는 것입니다.

자주 묻는 질문

디코딩한 바이트를 왜 다시 인코딩하나요?

일부 디코더는 끝부분의 미사용 비트가 0이 아니어도 입력을 받아들입니다. 패딩 없는 정규 Base64URL로 다시 인코딩한 뒤 원문과 비교하면 같은 바이트를 나타내는 비정규 표현을 거부할 수 있습니다.

이 디코더로 토큰 서명도 검증할 수 있나요?

아니요. 인코딩 형식만 검증하고 바이트를 반환합니다. 서명, 만료 시점, 발급자는 별도의 암호학적 검증과 애플리케이션 규칙으로 확인해야 합니다.

내 파이프라인으로 클라우드 Mac 검증하기

ArmRDP는 가상 머신이 아닌 전용 물리 Mac mini를 제공합니다. 하루 단위로 대여해 툴체인과 빌드 결과를 먼저 확인한 뒤, 팀에 맞는 대여 기간을 선택하세요.

클라우드 Mac 대여하기

구성을 투명하게, 빌드를 재현 가능하게

모델과 대여 기간, 접속 절차를 확인한 뒤 전용 물리 노드에 작업을 맡기세요.