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

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

`Cors.anyOrigin()`은 `*`로 답하며, 이는 공개 읽기 전용 API에 알맞다. 이것은 `allowCredentials()`와 결합할 수 없다. 명세는 와일드카드와 쿠키의 조합을 명백히 금지하므로, 이 조합은 브라우저에서 조용히 실패하는 대신 그것이 쓰인 자리에서 예외를 던진다.

직접 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(…​) 다운로드를 메모리 밖에 두는 방법이다. 그 크기는 미리 알 수 없으므로, `minBytes`는 여기에 적용되지 않고 `Content-Length`는 제거된다.

Server-Sent Events와 raw 응답은 결코 압축되지 않는다. 압축할 가치가 생길 때까지 버퍼링된 SSE 스트림은 더 이상 도착하지 않는 SSE 스트림이며, raw 핸들러는 정의상 자신의 바이트를 스스로 소유한다. JPEG, PNG, MP4, zip 아카이브처럼 이미 압축된 타입도, 다른 무언가가 이미 인코딩한 응답도 압축되지 않는다. .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가 들어 있는 경로가 사용자가 클릭해 이동하는 어디로도 새어 나가지 않는다.

두 개는 더 요청하기 전까지 꺼져 있는데, 둘 다 동작하는 사이트를 망가뜨릴 수 있고 애플리케이션만이 그것을 알기 때문이다:

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

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

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