클라우드 Mac에서 검증하는 iOS XML 파싱: 외부 엔터티와 자원 제한

클라우드 Mac에서 검증하는 iOS XML 파싱: 외부 엔터티와 자원 제한

클라우드 Mac에서 검증하는 iOS XML 파싱: 외부 엔터티와 자원 제한

로컬에서는 문제없이 읽히는 XML도 무인 파이프라인에 넣으면 처리가 멈출 수 있습니다. 외부 엔터티 선언이 들어 있거나, 요소가 지나치게 깊게 중첩되어 있거나, 이름이 같은 필드가 서로 다른 네임스페이스에 속할 수 있기 때문입니다. 클라우드 Mac의 역할은 이런 입력을 어떻게든 복구하는 것이 아니라, 허용 범위를 벗어난 문서를 일관되게 거부하고 실패를 재현할 수 있는 샘플을 남기는 것입니다.

파서를 호출하기 전에 입력 계약부터 정의하기

iOS 설정 가져오기 기능을 예로 들어 보겠습니다. UTF-8만 허용하고 DTD는 금지하며, 입력 하나의 크기는 최대 256 KiB, 요소 깊이는 최대 16단계, 모든 텍스트 콜백으로 전달되는 데이터의 누적 크기는 최대 32 KiB로 정합니다. 이 수치는 이 글에서 사용하는 예시 한도이지, XML 표준이나 시스템 기본값이 아닙니다. 실제 서비스 요구에 맞게 조정해야 합니다.

형식 오류, 한도 초과, 비즈니스 필드 오류는 구분해서 기록합니다. 파서가 성공을 반환했다는 이유만으로 알 수 없는 루트 요소, 중복된 단일값 필드, 필수 항목이 빠진 문서를 받아들여서는 안 됩니다.

외부 엔터티 해석을 끄는 것과 DTD를 금지하는 것은 다릅니다. 그것만으로 완전한 자원 격리가 보장되는 것도 아닙니다. 형식 제약, 파서 설정, 프로세스 수준의 타임아웃은 각각 별도로 검증해야 합니다.

클라우드 Mac에서 최소한의 안전성 검사 실행하기

아래 코드를 xml-guard.swift로 저장합니다. 이 코드는 구문과 자원 한도만 검사하며 비즈니스 모델은 생성하지 않습니다. 스크립트는 보수적으로 동작합니다. 주석 안에 DTD 선언 표식이 있어도 거부합니다. 이런 단순 텍스트 검사를 완전한 XML 어휘 분석으로 오해해서는 안 됩니다.

import Foundation

final class GuardDelegate: NSObject, XMLParserDelegate {
    var depth = 0
    var textBytes = 0
    var exceeded = false

    func stop(_ parser: XMLParser) {
        exceeded = true
        parser.abortParsing()
    }

    func parser(
        _ parser: XMLParser,
        didStartElement elementName: String,
        namespaceURI: String?,
        qualifiedName qName: String?,
        attributes attributeDict: [String: String] = [:]
    ) {
        depth += 1
        if depth > 16 { stop(parser) }
    }

    func parser(
        _ parser: XMLParser,
        didEndElement elementName: String,
        namespaceURI: String?,
        qualifiedName qName: String?
    ) {
        depth -= 1
    }

    func parser(_ parser: XMLParser, foundCharacters string: String) {
        textBytes += string.utf8.count
        if textBytes > 32 * 1024 { stop(parser) }
    }

    func parser(_ parser: XMLParser, foundCDATA data: Data) {
        textBytes += data.count
        if textBytes > 32 * 1024 { stop(parser) }
    }

    func parser(
        _ parser: XMLParser,
        resolveExternalEntityName name: String,
        systemID: String?
    ) -> Data? {
        nil
    }
}

func validate(_ path: String) throws -> Bool {
    let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
    defer { try? handle.close() }

    var data = Data()
    while true {
        guard let chunk = try handle.read(upToCount: 8192),
              !chunk.isEmpty else { break }
        data.append(chunk)
        if data.count > 256 * 1024 { return false }
    }

    guard let source = String(data: data, encoding: .utf8),
          !source.contains("\0"),
          !source.contains("<!DOCTYPE") else {
        return false
    }

    let delegate = GuardDelegate()
    let parser = XMLParser(data: data)
    parser.delegate = delegate
    parser.shouldProcessNamespaces = true
    parser.shouldResolveExternalEntities = false
    parser.externalEntityResolvingPolicy = .never

    let parsed = parser.parse()
    return parsed && !delegate.exceeded && delegate.depth == 0
}

guard CommandLine.arguments.count == 2 else { exit(2) }
do {
    exit(try validate(CommandLine.arguments[1]) ? 0 : 1)
} catch {
    exit(2)
}

도구 체인이 선택된 터미널에서 컴파일한 뒤, 유효한 샘플 하나를 실행합니다.

xcrun swiftc xml-guard.swift -o xml-guard
printf '%s\n' '<settings><name>demo</name></settings>' > valid.xml
./xml-guard valid.xml

종료 코드는 0이면 통과, 1이면 입력 거부, 2이면 호출 또는 파일 읽기 실패를 뜻합니다. 파이프라인에서는 이 코드를 기준으로 결과를 분류하고, 시스템 언어에 따라 달라지는 오류 문장을 파싱하지 않습니다. 입력 진입점은 작업이 직접 임시 저장한 일반 파일만 받아야 하며, 임의의 장치 경로를 입력으로 허용해서는 안 됩니다.

고정된 샘플로 거부 경로 검증하기

최소한 여섯 종류의 파일을 유지합니다. 유효한 문서, 닫히지 않은 태그가 있는 문서, DTD가 포함된 문서, 17단계로 중첩된 문서, 텍스트 한도를 초과한 문서, 입력 바이트 한도를 초과한 문서입니다. 다음은 경계 조건을 검증할 샘플 두 개를 생성하는 명령입니다. 실행 환경에는 Python 3가 필요합니다.

python3 - <<'PY'
from pathlib import Path

Path("deep.xml").write_text(
    "<n>" * 17 + "</n>" * 17, encoding="utf-8"
)
Path("text-limit.xml").write_text(
    "<n>" + "x" * (32 * 1024 + 1) + "</n>", encoding="utf-8"
)
PY
./xml-guard deep.xml
./xml-guard text-limit.xml

두 실행 모두 1을 반환해야 합니다. 비교 연산자를 잘못 쓰는 실수를 잡으려면, 한도와 정확히 같은 샘플과 한도보다 1바이트만 큰 샘플도 한 쌍으로 준비합니다. 외부 엔터티가 포함된 샘플은 파싱 전에 거부되어야 합니다. 파서의 외부 자원 설정을 별도로 검증하려면 통제된 테스트 대상을 사용하고, 실제 접근이 발생하지 않았음을 단언해야 합니다. 결과가 비어 있다는 이유만으로 안전하다고 판단해서는 안 됩니다.

네임스페이스와 텍스트 콜백은 구조에 맞게 처리하기

네임스페이스 처리를 활성화했다면 비즈니스 필드는 “네임스페이스 URI + 로컬 이름”으로 식별해야 합니다. 접두사만 비교하거나 elementName만 비교해서는 안 됩니다. 접두사는 바뀔 수 있고, URI가 다르면 같은 name도 서로 다른 개념을 나타낼 수 있습니다. 접두사만 바뀌면 여전히 허용하고, URI가 바뀌면 반드시 거부하는지 모두 테스트해야 합니다.

foundCharacters가 필드 전체를 한 번에 전달한다는 보장은 없습니다. 실제 디코더는 요소 스택을 유지하면서 텍스트를 차례로 이어 붙이고, 요소가 끝날 때 길이, 형식, 필드 중복 여부를 검사해야 합니다. CDATA는 별도의 콜백으로 전달되므로 집계에서 빠뜨려서는 안 됩니다. 또한 모든 필드의 앞뒤 공백을 일괄 제거하지 않아야 합니다. 표시 이름과 공백을 보존해야 하는 본문은 서로 다른 계약을 가질 수 있습니다.

불완전한 결과는 커밋하지 않기

모든 레코드는 먼저 해당 작업 전용 임시 모델에 저장합니다. 구문, 한도, 루트 요소, 비즈니스 검사를 모두 통과한 경우에만 한 번에 커밋합니다. 문서 끝에 의도적으로 잘못된 태그를 추가해, 앞에서 이미 읽은 레코드도 데이터베이스에 저장되지 않는지 확인합니다.

일회성 수동 테스트 대신 빌드에 검증 연결하기

OSXBuild 클라우드 Mac에서 먼저 명령줄 샘플을 실행한 다음, 같은 파일을 iOS 테스트 타깃에 넣어 앱이 실제로 사용하는 디코더를 호출합니다. 두 구현은 같은 계약을 따라야 하지만, 데스크톱 스크립트가 통과했다고 해서 시뮬레이터에서 구현을 검증한 것으로 볼 수는 없습니다.

회귀 테스트 기록에는 샘플 이름, 예상 종료 코드, 실제 결과, 도구 체인 버전만 남기면 됩니다. 가져온 내용 전체를 출력하지 않습니다. 비정상 작업은 파이프라인 타임아웃으로 제한합니다. 콜백의 한도 검사는 이미 전달된 데이터만 제한할 수 있으며, 파서 내부 메모리 사용량의 절대 상한을 보장하지는 않습니다.

마지막으로 항목별로 확인합니다. 유효한 파일은 가져올 수 있는지, 한도를 벗어난 파일은 일관되게 실패하는지, 네임스페이스가 다른 필드가 섞이지 않는지, 실패 후 불완전한 결과가 남지 않는지 점검합니다. 이렇게 해야 다음에 파싱 로직을 수정할 때도 “이번에는 멈추지 않았다”가 아니라, 재현 가능한 입력과 명확한 단언을 기준으로 검증할 수 있습니다.

자주 묻는 질문

외부 엔터티 해석만 끄면 충분한가요?

아닙니다. 외부 엔터티 설정은 내부 DTD를 금지하지 않습니다. 이 글에서는 DTD가 없는 UTF-8 XML만 허용하고 파서 실행 전에 보수적으로 검사합니다.

foundCharacters는 필드 전체를 한 번에 전달하나요?

아닙니다. 같은 필드의 텍스트가 여러 콜백으로 나뉠 수 있습니다. 요소가 끝날 때까지 누적하고 문서 전체의 파싱과 업무 검증이 성공한 뒤 결과를 저장해야 합니다.

빌드 전용 클라우드 Mac을 사용해 보세요

물리 머신 한 대를 단독으로 사용하며, 가상 머신이 아닙니다. 빌드 규모에 맞춰 기종과 이용 기간을 선택하고, 아카이브·코드 서명·캐시를 나만의 노드에 보관하세요.

클라우드 Mac 구성 선택

다음 아카이브는 전용 클라우드 Mac에서 빌드하세요

빌드 규모에 맞는 머신을 고르고, 팀 위치에 맞는 노드를 선택하세요. 구성을 확인한 뒤 툴체인 이전을 시작하면 됩니다.