보안 및 운영
한눈에 보기
섹션 제목: “한눈에 보기”이 브리지는 사용자의 HTML을 네트워크 경계 너머의 브라우저 엔진으로 전송합니다. 이 페이지는 그 경계를 방어하는 모든 컨트롤을 소스 기준으로 설명합니다. 컨트롤이 표준을 인용하는 경우, 그 인용은 코드 자체의 docblock이 선언하는 내용입니다. 이 페이지는 코드가 주장하는 내용을 다시 설명할 뿐, 규범적 문구를 재구성하지 않습니다.
위협 모델
섹션 제목: “위협 모델”패키지 자체의 docblock은 방어 대상 위협을 다음과 같이 명시합니다:
- XSS-to-PDF — 렌더링 중에 실행되는 악의적 마크업.
- SSRF — 내부 주소로 요청을 유도하는 마크업 또는 대상 URL.
- 리소스 고갈 — 과도하게 큰 입력 또는 압축 폭탄.
- DNS 리바인딩 — 검증은 통과하지만 연결 시점에 사설 주소로 해석되는 호스트명.
- 경로상의 TLS 가로채기 — Worker로 가는 경로에서 교체된 인증서.
각 항목은 아래의 구체적이고 테스트 가능한 컨트롤로 대응합니다.
입력 컨트롤(요청이 PHP를 떠나기 전)
섹션 제목: “입력 컨트롤(요청이 PHP를 떠나기 전)”CloudflareSecurityPolicy::validate()는 요청이 구성되기 전에 실행됩니다:
| 컨트롤 | 동작 | 제한 출처 |
|---|---|---|
| 크기 상한 | maxHtmlSize보다 큰 HTML을 거부합니다 | CloudflareRendererConfig, 기본값 5000000바이트 |
| Base64 압축 폭탄 가드 | 모든 data:…;base64,… URI의 디코딩 크기를 추정하여, 상한에 도달하거나 초과하면 거부합니다 | MAX_DATA_URI_BYTES = 13631488 |
| Meta-refresh 금지 | 대소문자를 구분하지 않고 모든 <meta http-equiv="refresh">를 거부합니다 | CloudflareSecurityPolicy의 정규식 |
위반이 발생하면 위반 값과 제한을 명시한 메시지와 함께 RuntimeException이 발생합니다. meta-refresh 금지가 필요한 이유는 refresh 디렉티브가 Worker가 렌더링하는 페이지 내부에서 내비게이션을 유도할 수 있기 때문입니다. 이는 URL이 아니라 콘텐츠 안에 존재하는 SSRF 벡터입니다.
nextpdf/core의 HTML 보안 정책(HtmlSecurityPolicyInterface, 기본값 DefaultHtmlSecurityPolicy)은 파싱 계층에서 동작하며, 위의 전송 계층 검사를 보완합니다. 이 정책은 getHtmlSecurityPolicy()로 조회합니다. 사용자 정의 정책은 생성자를 통해 주입합니다.
대상 컨트롤(SSRF 및 DNS 리바인딩)
섹션 제목: “대상 컨트롤(SSRF 및 DNS 리바인딩)”CloudflareSecurityPolicy::validateWorkerUrl():
- 파싱되지 않거나 scheme/host가 없는 URL을 거부합니다(
Invalid Worker URL). - HTTPS가 아닌 모든 스킴을 거부합니다(
Worker URL must use HTTPS). - IP 리터럴 호스트의 경우, 다음을 사용해 사설 또는 예약 범위를 거부합니다
PHP의
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE. 실제로 이는 RFC 1918 사설 공간, 루프백, RFC 3927 링크 로컬 주소를 거부합니다. 테스트는192.168.x,127.0.0.1,169.254.x의 거부를 명시적으로 검사합니다. 범위 소속 여부는 이 패키지가 고정한 조항이 아니라 PHP의 필터 확장에 따라 결정됩니다. RFC 1918과 RFC 3927은 여기서 해당 범위의 잘 알려진 정의로서 설명을 위해 언급됩니다. - 호스트명의 경우,
dns_get_record()를 통해 모든 A 및 AAAA 레코드를 해석합니다(첫 번째 응답만 반환하는gethostbyname()가 아닙니다). 해석된 주소 중 어느 하나라도 사설 또는 예약 주소이면 거부합니다.
모든 레코드를 해석하는 것은 의도적인 동작이며, 클래스 docblock에 방어 수단으로 문서화되어 있습니다. 단일 레코드 조회가 공개 주소를 선택하더라도 이후 연결이 사설 주소를 선택할 수 있는, 여러 레코드를 반환하는 호스트를 방어하기 위한 것입니다. 이는 OWASP SSRF Prevention Cheat Sheet와 일치하며, 해당 문서는 애플리케이션이 도메인 이름 뒤에 있는 모든 IP 주소(A 및 AAAA 레코드)를 가져와 그 모두에 비공개 주소 검사를 적용하도록 안내합니다.
validateWorkerUrl()는 검증된 IP 집합을 반환합니다. 그런 다음 렌더러는 전송 직전에 assertPinsStillValid()를 호출합니다. 이 호출은 호스트를 다시 해석하고, 검증 이후 새 IP가 나타났으면 거부합니다(Worker URL DNS answer changed since validation — possible DNS rebinding attack). 이는 검증과 연결 사이의 점검 시점/사용 시점(time-of-check / time-of-use) 구간을 닫습니다.
전송 컨트롤(PinnedCurlTransport)
섹션 제목: “전송 컨트롤(PinnedCurlTransport)”검증된 IP 집합 또는 SPKI 고정 집합이 있고 또한 PSR-17 ResponseFactory가 제공된 경우, 렌더러는 주입된 PSR-18 클라이언트 대신 Transport\PinnedCurlTransport를 사용합니다. 이 전송은 cURL 핸들 계층에서 다음을 강제합니다:
- 고정 DNS —
CURLOPT_RESOLVE는 host:port를 검증된 IP 집합에 바인딩하므로 libcurl이 연결 시점에 자체 조회를 수행하지 않습니다. 이것이 사용자 영역의 DNS 검사가 실제로 연결을 바인딩하게 만드는 요소입니다. 이 설정이 없으면 libcurl이 다른 주소를 해석할 수 있습니다. - TLS 공개 키 고정 —
CURLOPT_PINNEDPUBLICKEY가 결합된 고정 집합에서 설정됩니다. 이는 RFC 7469 §2.6을 따릅니다. 서버가 제시한 SPKI 지문 집합이 구성된 고정 집합과 교집합을 이룰 때 고정된 연결이 수락되며, 고정 검증 실패는 복구 불가능한 것으로 취급됩니다. 고정 문자열은sha256/<base64>에서 cURL의sha256//<base64>형식으로 정규화됩니다. 잘못된 형식의 고정은InvalidSpkiPinException을 발생시킵니다. - TLS 검증 활성화 —
CURLOPT_SSL_VERIFYPEER => true,CURLOPT_SSL_VERIFYHOST => 2. - 자동 리다이렉트 없음 —
CURLOPT_FOLLOWLOCATION => false,CURLOPT_MAXREDIRS => 0. 3xx는 libcurl이 검증되지 않은 호스트로 따라가지 않고 정책 계층에 노출됩니다. 클래스 docblock은 리다이렉트가 조용히 따라가지 않고 재검증되도록 하는 의도적 선택이라고 명시합니다. - 하드 타임아웃 —
CURLOPT_TIMEOUT은renderTimeout에서 설정됩니다(기본값30초).
cURL 오류가 발생하거나 본문이 문자열이 아니면 cURL 오류 번호 및 메시지와 함께 CloudflareRenderException을 발생시킵니다.
고정 운영 지침
섹션 제목: “고정 운영 지침”구성은 pinnedPublicKeys와 별도의 backupPublicKeys를 포함합니다. RFC 7469 §2.5는 백업 고정(오프라인으로 보관된, 아직 배포되지 않은 보조 키 쌍의 지문)을 우발적인 고정 검증 실패에서 복구하는 주된 방법으로 설명합니다. 인증서 교체가 엔드포인트를 망가뜨리지 않도록 최소 하나의 백업 고정을 유지하는 것은 그 지침을 따르는 조치입니다. 별도의 필드는 교체를 독립적으로 검증할 수 있게 합니다. 운영 시:
- 교체를 직접 제어할 수 있는 리프 또는 중간 인증서의 SPKI를 고정합니다.
- 교체하기 전에 항상 다음 인증서에 대한 백업 고정을 구성합니다.
- 빈 고정 집합은 고정을 비활성화합니다. 안정적이고 알려진 인증서 체인에서만 사용하십시오. 고정은 구성으로 옵트인하는 방식입니다.
인증 및 시크릿 처리
섹션 제목: “인증 및 시크릿 처리”- Worker 요청은
Authorization: Bearer <apiToken>를 포함합니다.apiToken은#[SensitiveParameter]이므로 스택 트레이스에서 가려집니다. 도달성 프로브는 HTTPHEAD에서 동일한 Bearer 헤더를 전송합니다. - R2 액세스 키(
accessKeyId,secretAccessKey)는#[SensitiveParameter]이며 AWS Signature V4 서명 키를 파생하는 데만 사용됩니다. ApiKeyValidator는hash_equals()(타이밍 안전)로 키를 비교하고,validateHashed()를 통해 SHA-256 해시 키 저장을 지원합니다.- 구성 객체는
final readonly입니다. 한 번 설정된 시크릿은 변경할 수 없습니다. - 시크릿은 환경 변수 또는 시크릿 관리자에서 가져옵니다. 절대 커밋하지 마십시오. 이 패키지는 더 넓은 NextPDF 보안 기준선을 따릅니다: PHPStan Level 10, 모든 파일에
declare(strict_types=1),eval()/exec()미사용, SHA로 고정된 GitHub Actions.
이 패키지가 주장하지 않는 것
섹션 제목: “이 패키지가 주장하지 않는 것”- Cloudflare 플랫폼 제한(Worker CPU 시간, 메모리, 요청 본문 상한, 또는 서브요청 수)은 명시하지 않습니다. 이 문서가 명시하는 유일한 크기 및 시간 제한은 패키지 자체가 강제하는 항목이며, 위와 /integrations/cloudflare/configuration/.에 나열되어 있습니다. 플랫폼 제한은 Cloudflare의 공식 문서와 사용자 Worker의 자체 구현을 참조하십시오.
- PDF에 서명하지 않으며 서명 적합성에 대해 어떠한 주장도 하지 않습니다. 서명이 필요한 경우, 여기서 렌더링한 다음 서명 엔진으로 서명하십시오. NextPDF Pro는 PAdES B-B 서명만 제공합니다. 장기 검증 프로파일은 Enterprise 기능이며 이 브리지의 범위를 벗어납니다.
- 파이프라인을 인증하거나 보증하거나 “변조 불가”로 만들지 않습니다. 이 페이지에 설명된 구체적이고 소스로 검증 가능한 컨트롤을 구현할 뿐, 그 이상은 아무것도 구현하지 않습니다.
운영 런북
섹션 제목: “운영 런북”| 증상 | 첫 번째 확인 사항 |
|---|---|
Worker URL must use HTTPS | 구성된 workerUrl 스킴. |
private or reserved IP | Worker 호스트명의 DNS 레코드. RFC 1918 / 루프백 / RFC 3927 공간으로 해석되는 레코드. |
DNS answer changed since validation | DNS 불안정 또는 리바인딩 시도. 다시 해석하여 레코드 집합을 점검하십시오. |
cURL transport error | 네트워크 경로, TLS 체인, 그리고 고정이 설정된 경우 제공된 인증서의 SPKI가 여전히 고정 집합에 있는지 여부. |
| 인증서 교체 직후 렌더링 실패 | 일치하는 백업 고정이 없는 고정 집합. 교체하기 전에 새 SPKI 를 백업으로 추가하십시오. |
is not installed / no LocalRendererFactoryInterface | 폴백은 활성화되었지만 팩토리가 연결되지 않았거나, nextpdf/artisan이 없습니다. |
| 노드 간 속도 제한 거부가 일관되지 않음 | 메모리 내 제한기는 프로세스별로 동작합니다. 공유 저장소로 앞단을 구성하십시오. |
인시던트 보고
섹션 제목: “인시던트 보고”취약점은 GitHub Security Advisories 또는 저장소 SECURITY.md의 보안 연락처를 통해 보고하십시오. 보안 이슈를 공개 GitHub 이슈로 등록해서는 안 됩니다.
관련 항목
섹션 제목: “관련 항목”- /integrations/cloudflare/overview/ — 패키지가 이 경계를 중심으로 구성된 이유.
- /integrations/cloudflare/configuration/ — 고정 집합 및 제한 필드.
- /integrations/cloudflare/troubleshooting/ — 전체 실패-예외 매핑.