CORS, 압축, 보안 헤더

CORS, 압축, 보안 헤더는 각각 필터로 등록하는 대신 App에서 이름으로 지정한다. 배포된 애플리케이션 거의 전부가 셋 모두를 켠다:

app.cors(Cors.allowOrigin("https://app.example.com").forPath("/api/*"))
   .gzip()
   .securityHeaders();

각 값은 등록될 때 복사된다. 이후에 바꾼 Cors, Gzip, SecurityHeaders는 애플리케이션이 보내는 것을 아무것도 바꾸지 않으므로, 필드에 보관한 값으로 실행 중인 서버를 재구성할 수는 없다.

왜 필터가 아니고, 왜 플러그인이 아닌가

beforeRoute나 afterRoute 필터는 라우트가 매칭된 뒤에만 실행되는데, 이 셋은 매칭된 라우트가 없는 답에도 닿아야 한다. CORS 프리플라이트는 아무도 핸들러를 등록하지 않은 OPTIONS이다. 교차 출처 404는 CORS 헤더를 실어야 하며, 그렇지 않으면 브라우저는 실제로 일어난 404 대신 CORS 실패를 보고한다. 보안 헤더는 다른 어떤 페이지 못지않게 에러 페이지에도 있어야 한다. 대부분의 애플리케이션이 보내는 가장 큰 응답은 스타일시트이고, 스타일시트는 정적 파일이어서 라우팅이 필터에 이르지 않은 채 답해진다.

따라서 이들은 AppServlet 안에서, 답이 정해진 뒤 전송되기 전에 실행되며, 그곳이 라우트, 정적 파일, 에러의 경로가 모두 만나는 지점이다. 응답 필터도 그 답들에 닿으며, 이 셋보다 먼저 실행된다. 이들이 App에서 이름으로 지정된 채로 남는 이유는, 이들보다 먼저 실행되는 필터가 이들을 되돌릴 수 없기 때문이고, CORS 프리플라이트가 Allow 헤더가 정해지는 동안, 즉 요청 필터 뒤이자 응답 필터 전에 답해지기 때문이다. 각각은 스스로 동작하지 않는 값 하나를 받는 이름 있는 메서드 하나이며, staticFiles(StaticFiles)와 requestLogger(…​)와 같은 모양이다. 이 모양은 의도적으로 플러그인 레지스트리가 아니다: 스스로를 구성하는 것들의 레지스트리는 컨테이너의 시작이다.

이름으로 지정하기 전까지는 아무것도 켜지지 않는다. 셋 중 어느 것도 지정하지 않은 App은 이 셋이 존재하기 전과 정확히 같이 동작한다.

CORS

Cors는 어떤 다른 출처(origin)가 애플리케이션을 호출할 수 있는지, 그리고 무엇을 보낼 수 있는지 말한다:

app.cors(Cors.allowOrigin("https://app.example.com", "https://admin.example.com")
        .forPath("/api/*") // the default is "/*"
        .allowCredentials() // cookies may be sent
        .exposeHeaders("X-Total-Count") // a script may read this one back
        .maxAge(Duration.ofHours(1))); // how long a preflight answer lasts

그러면 두 가지가 일어난다. Cors 값이 허용한 Origin을 실은 요청에 대한 모든 응답은 Access-Control-Allow-Origin을 얻는다. 출처를 지정했다면, 적용 경로의 모든 응답은 요청이 허용된 Origin을 실었든, 다른 Origin을 실었든, 아무것도 싣지 않았든 Vary: Origin도 얻으므로, 공유 캐시가 한 출처의 답을 다른 출처에 건네지 않는다. 프리플라이트, 즉 Access-Control-Request-Method를 실은 OPTIONS는 그 자리에서 답해진다. Access-Control-Allow-Methods는 라우터가 그 경로에 대해 이미 정해 둔 Allow 헤더에서 온다. 답이 라우팅 테이블을 따르지 않아야 할 때는 allowMethods(…​)로 메서드를 직접 지정한다.

Cors.anyOrigin()은 로 답하며, 이는 공개 읽기 전용 API에 알맞다. 요청이 Origin을 실었든 아니든 적용 경로의 모든 응답에 를 붙이며, 답이 모든 호출자에게 같으므로 Vary: Origin은 붙이지 않는다. 이 설정은 allowCredentials()와 함께 쓸 수 없다. 명세는 와일드카드와 쿠키의 조합을 명백히 금지하므로, 이 조합은 브라우저에서 조용히 실패하는 대신 조합을 쓴 바로 그 자리에서 예외를 던진다.

직접 OPTIONS 라우트를 등록하면 그 라우트가 CORS에게서 프리플라이트를 넘겨받는다. OPTIONS 핸들러가 있는 경로는 헤더까지 포함하여 그 핸들러가 답한다. 라우트가 없는 경로에 대한 프리플라이트는 여전히 404이다.

CORS는 스크립트가 무엇을 읽을 수 있는지에 대해 브라우저가 강제하는 규칙이다. 브라우저가 아닌 호출자는 이 모든 것을 무시하므로, 인증과 인가는 여전히 before 필터의 일이다.

압축

gzip()은 클라이언트가 읽을 수 있다고 말한 모든 압축 가능한 응답을 압축한다:

app.gzip(); // the defaults

app.gzip(Gzip.defaults()
        .minBytes(4096) // 1024 by default
        .types("text/", "application/json"));

압축은 서블릿 응답을 감싸는 래퍼가 아니라 WebResponse에 대한 변환이다. 이미 메모리에 있는 본문(text, html, json, bytes, 또는 렌더링된 템플릿)은 그 자리에서 즉시 압축되므로, 알리는 길이가 곧 보내는 길이이다. stream 본문은 기록되면서 압축되므로, 큰 파일이나 stream(…​) 다운로드가 메모리에 통째로 올라가지 않는다. 작성기의 flush()는 flush마다 몇 바이트를 더 들여 압축된 채 디코딩 가능한 상태로 클라이언트에 도달하므로, 점진적인 본문이 작성기가 반환하기를 기다리지 않는다. 그 크기는 미리 알 수 없으므로, minBytes는 이 본문에 적용되지 않고 Content-Length는 제거된다. gzip 트레일러는 라이터가 반환할 때에만 쓰이므로, 아무것도 보내기 전에 실패한 라이터는 여전히 500을 받는다. 응답이 커밋된 뒤에 실패한 라이터는 다른 스트림과 마찬가지로 전송을 중단시킨다(요청 로깅 참고).

Server-Sent Events와 raw 응답은 결코 압축되지 않는다. 압축할 가치가 생길 때까지 버퍼링된 SSE 스트림은 이벤트가 보내는 즉시 도착하지 않는 SSE 스트림이다. raw 라이터는 정의상 자신이 쓰는 바이트를 스스로 소유한다. JPEG, PNG, MP4, zip 아카이브처럼 이미 압축된 타입도, 이미 Content-Encoding을 실은 응답도 압축되지 않는다. .br나 .gz 형제 파일에서 답한 정적 파일은 그중 하나이며, 두 번 압축되는 대신 손대지 않고 통과한다.

캐시에 알리는 것

압축 가능한 모든 답은 결국 압축되었든 아니든 Vary: Accept-Encoding을 실으므로, 캐시가 gzip된 바이트를 읽을 수 없는 클라이언트에 건네는 일은 결코 없다. 압축된 답의 ETag, 즉 정적 파일이 파일에서 도출하는 그 태그는 약한(weak) 것으로 표시된다. 압축된 본문과 압축되지 않은 본문은 같은 파일이지만, 같은 바이트는 아니다. 약한 태그로 재검증하는 브라우저는 여전히 304를 받는다.

보안 헤더

securityHeaders()는 모든 응답에 세 헤더를 보내는데, 이들이 없는 편이 더 나은 답인 애플리케이션은 없기 때문이다:

X-Content-Type-Options: nosniff

text/plain으로 제공된 업로드 파일이 스크립트로 다시 읽혀서는 안 된다.

X-Frame-Options: DENY

아무도 이 페이지를 프레임에 넣지 못하며, 이것이 클릭재킹을 막는다.

Referrer-Policy: strict-origin-when-cross-origin

id가 들어 있는 경로가 사용자가 클릭해 이동하는 어디로도 새어 나가지 않는다.

HSTS와 Content Security Policy는 요청하기 전까지 꺼져 있는데, 둘 다 동작하는 사이트를 망가뜨릴 수 있고 그럴지는 애플리케이션만 알기 때문이다:

app.securityHeaders(SecurityHeaders.defaults()
        .frameOptions("SAMEORIGIN") // the app embeds itself
        .hsts(Duration.ofDays(365)) // once HTTPS is certain
        .contentSecurityPolicy("default-src 'self'")
        .permissionsPolicy("camera=()")
        .header("X-Robots-Tag", "noindex")); // anything else, same point

HSTS는 이미 HTTPS로 도착한 요청에 대해서만 나간다. 평문 HTTP로 HSTS를 학습한 브라우저는 중간에 끼어든 누군가에게서 학습한 것이다. 따라서 TLS를 종단하는 프록시 뒤에서는 컨테이너에 X-Forwarded-Proto를 신뢰하라고 알려 주어야 하며, 그렇지 않으면 이 헤더는 아예 나가지 않는다. HSTS는 또한 여기서 되돌릴 수 없는 유일한 헤더이다: 1년이라고 들은 브라우저는 서버가 다음에 무엇을 말하든 1년 동안 그 설정을 유지한다. 먼저 짧은 기간을 설정하고, 사이트에서 HTTP가 필요한 것이 하나도 없게 되면 늘린다.

이 헤더들 중 하나를 직접 설정한 응답은 자신의 값을 유지하므로, 프레임에 들어가야 하는 단 하나의 페이지는 헤더를 모든 곳에서 끄지 않고도 자신의 응답에 그 헤더를 직접 설정한다:

app.get("/widget", req -> WebResponse.template("widget")
        .header("X-Frame-Options", "SAMEORIGIN"));