빌드노트
default-src를 막았는데 폼은 다른 주소로 제출된다
default-src 'none'이어도 폼의 POST는 다른 출처에 도착할 수 있다. 두 loopback 출처의 실제 수신과 위반 이벤트를 비교하고, 제출을 시작하는 HTML 문서의 CSP에서 form-action으로 필요한 목적지만 허용하는 방법을 설명한다.
Content-Security-Policy: default-src 'none'을 보냈는데도 폼의 POST가 다른 출처에 도착할 수 있다. form-action에는 default-src 대체 적용이 없다. 폼을 담은 문서의 정책에 제출 목적지를 따로 적어야 한다. 외부 제출이 필요 없는 페이지라면 기존 CSP에 form-action 'self'를 명시한다. MDN의 form-action 설명 (새 탭에서 열림)은 이 지시어를 생략하면 default-src가 대신 제한하지 않는다고 설명한다.
폼을 담은 HTML 응답부터 확인한다
개발자 도구의 Network를 열고 폼이 있는 페이지를 다시 불러온다. 제출받는 서버의 응답이 아니라 제출을 시작하는 HTML 문서의 응답에서 Content-Security-Policy를 읽는다. 그 정책에 default-src만 있고 form-action이 없다면 폼 목적지 제한이 빠져 있다.
헤더 이름이 Content-Security-Policy-Report-Only인지도 구별한다. 이 헤더는 정책 효과를 관찰하는 용도이며 그 정책으로 차단하지 않는다. CSP3의 정책 전달 규칙 (새 탭에서 열림)에 따른 설명이다. 아래 실험은 강제 적용하는 Content-Security-Policy 헤더를 사용했다.
Elements에서 폼의 action과 눌렀던 제출 버튼을 살펴보고 실제 요청 URL과 비교한다. 버튼에 formaction이 있으면 폼의 action을 덮어쓸 수 있다. MDN의 버튼 속성 설명 (새 탭에서 열림)에 나오는 규칙이며, 아래 실험에는 이 속성을 넣지 않았다.
같은 출처로만 제출할 페이지라면 기존 CSP 값에 다음 지시어를 세미콜론으로 구분해 추가한다. 이미 form-action이 있다면 그 값을 수정한다. 이것은 별도 HTTP 헤더 이름이 아니라 Content-Security-Policy 값 안에 들어가는 조각이다.
form-action 'self'
수정한 HTML 응답에 실제로 이 값이 나타나는지 확인한다. 앱이나 프록시가 기존에 보내던 나머지 지시어는 보존한다.
이미지는 막혔지만 다른 출처가 POST를 받았다
별도 재현에서 문서 출처 A는 http://127.0.0.1:16798, 수신처 B는 http://127.0.0.1:16799였다. 호스트가 같아도 포트가 달라 서로 다른 출처다. 양쪽의 /receive는 POST 본문을 기록하고 바로 200을 반환하도록 준비했다. 제출 후 리다이렉트는 없었다.
페이지 스크립트 없이 제출 버튼과 무해한 표식만 넣었다. 아래는 첫 조건에서 B로 전송한 입력이다. 이 주소는 실험 당시의 수신처이며, 자기 개발 환경에서 확인할 때는 직접 관리하는 수신 주소를 사용한다.
<form method="post" action="http://127.0.0.1:16799/receive">
<input type="hidden" name="marker" value="default-cross">
<button type="submit">Send marker</button>
</form>
A의 첫 응답 정책은 정확히 default-src 'none'이었다. 새 브라우저 프로필에서 버튼을 한 번 누르자 B는 marker=default-cross를 수신했고 브라우저 주소도 B의 /receive로 바뀌었다. 수신 요청의 Sec-Fetch-Mode는 navigate였다. JavaScript의 fetch를 실행한 결과가 아니다.
정책 자체가 빠진 것은 아닌지도 확인했다. 같은 페이지의 /probe.png 이미지 요청은 서버에 도착하지 않았고 img-src 위반 이벤트가 기록됐다. 별도 img-src가 없어서 default-src 'none'이 이미지 로딩을 제한한 것이다. 리소스 로딩 제한이 작동하는 문서에서도 폼은 다른 출처로 제출됐다.
이어서 폼 목적지는 B로 유지하고 A의 정책만 default-src 'none'; form-action 'self'로 바꿨다. 이번에는 B의 POST 수신이 0건이었고 브라우저가 A에 남았다. 수집한 securitypolicyviolation 이벤트에서 차단 이유를 구별하는 필드는 다음과 같았다.
{
"effectiveDirective": "form-action",
"blockedURI": "http://127.0.0.1:16799/receive",
"disposition": "enforce"
}
화면 이동이 없다는 사실만으로 차단을 판단하지 않았다. 목적지 수신 기록과 form-action 위반을 함께 대조했다. 이벤트 관찰 코드는 브라우저 자동화의 초기화 기능으로 등록했으며, 수집을 위해 페이지의 CSP에 스크립트 허용 예외를 넣지는 않았다.
같은 출처 제출과 명시적 허용도 비교했다. 아래 모든 조건은 default-src 'none'을 공통으로 유지하고 버튼을 한 번씩 누른 결과다. 이벤트 수에서는 이미지 위반을 제외하고 form-action만 셌다.
form-action 생략 → B: POST 수신 1건, form-action 이벤트 0건
form-action 'self' → B: POST 수신 0건, form-action 이벤트 1건
form-action 'self' → A: POST 수신 1건, form-action 이벤트 0건
form-action 'self' + B → B: POST 수신 1건, form-action 이벤트 0건
form-action 'none' → A: POST 수신 0건, form-action 이벤트 1건
네 번째 줄의 + B는 결과를 읽기 위한 약칭이다. 실제 값은 form-action 'self' http://127.0.0.1:16799였다. 'self'는 같은 출처 제출을 허용했고, 'none'은 같은 출처 제출도 막았다.
필요한 수신처만 form-action에 적는다
default-src의 ‘기본’은 모든 브라우저 동작의 기본값이라는 뜻이 아니다. CSP3의 default-src 절 (새 탭에서 열림)은 다른 fetch 지시어의 대체값을 정의한다. form-action 절 (새 탭에서 열림)은 폼 제출 목적지를 검사하는 내비게이션 지시어를 정의한다. 확인한 문서는 2026년 9월 16일자 Working Draft다.
같은 출처에만 제출하면 'self', 제출 자체가 필요 없으면 'none'을 선택한다. 외부 수신처가 필요하다면 승인한 정확한 스킴·호스트·포트를 form-action 목록에 적는다. 실험의 B 주소를 그대로 복사하거나, 수신처를 확인하지 않고 * 또는 넓은 도메인 범위를 허용하지 않는다.
수정 후에는 개발 환경에서 무해한 표식으로 허용할 제출과 막을 제출을 각각 확인한다. 허용 조건에서는 수신 서버에 표식이 도착하는지 보고, 차단 조건에서는 수신이 없으면서 위반 지시어가 form-action인지 본다. 허용 조건도 도착하지 않으면 폼 유효성 검사, submit 처리, 실제 요청 URL을 먼저 살펴본다. 수신 0건만으로 CSP가 원인이라고 정할 수는 없다.
이번 비교는 Windows 11, Python 3.12.10, agent-browser 0.27.0과 headless Chrome 153에서 실행했다. User-Agent가 보고한 버전은 153.0.0.0이다. 새 프로필의 최초 쿠키 목록은 비어 있었고 로그인 정보는 사용하지 않았다. 각 클릭 뒤 1초를 기다린 다음 URL·수신·위반을 수집했다. 차단 조건의 수신 0건은 그 수집 시점까지의 결과다.
검증 범위는 두 HTTP loopback 출처 사이의 직접 POST 제출이다. 다른 브라우저, GET 폼, 제출 후 리다이렉트, 버튼의 formaction, JavaScript가 제출을 가로채는 조건은 실행하지 않았다. 특히 MDN의 리다이렉트 주의사항 (새 탭에서 열림)은 구현 차이가 있음을 알린다. 이번 결과로 제출 후 리다이렉트 경로까지 차단된다고 판단하지 않는다.