Repository: tipsy/javalin (kotlin)

Issue:
Security headers: Simplify setting of headers, possibly via plugin
While working on an application, I left one of my not favourite todo's unchecked: Taking care of setting secure headers.

There is a [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/) that defines a couple of headers that should be set. I remember a common implementation used to be [helmet](https://helmetjs.github.io) (express based, so a bit outdated), that set a couple of these headers properly.

Would it make sense to have a plugin similar to the `CorsPlugin`, that allows to set those headers via a builder or do you think everything should be done manually anyway?

Happy to get any opinions and also to collect a list of headers/values if it makes sense to you...

Pull request description:
This introduces a `Headers` class with a few pre-defined header names to
have a DSL to easily configure headers. This has been created with the
OWASP security headers in mind, but can be used for anything.

Closes #1246

Patch:
diff --git a/javalin/src/main/java/io/javalin/core/JavalinConfig.java b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
index ac4e9a095..2562c4112 100644
--- a/javalin/src/main/java/io/javalin/core/JavalinConfig.java
+++ b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
@@ -19,6 +19,8 @@
 import io.javalin.core.security.SecurityUtil;
 import io.javalin.core.util.CorsPlugin;
 import io.javalin.core.util.Header;
+import io.javalin.core.util.Headers;
+import io.javalin.core.util.HeadersPlugin;
 import io.javalin.core.util.LogUtil;
 import io.javalin.http.Handler;
 import io.javalin.http.RequestLogger;
@@ -200,6 +202,11 @@ public JavalinConfig compressionStrategy(CompressionStrategy compressionStrategy
         return this;
     }
 
+    public JavalinConfig globalHeaders(Supplier<Headers> headers) {
+        registerPlugin(new HeadersPlugin(headers.get()));
+        return this;
+    }
+
     public static void applyUserConfig(Javalin app, JavalinConfig config, Consumer<JavalinConfig> userConfig) {
         userConfig.accept(config); // apply user config to the default config
 
diff --git a/javalin/src/main/java/io/javalin/core/util/Header.kt b/javalin/src/main/java/io/javalin/core/util/Header.kt
index 0eb3c472b..25c66805a 100644
--- a/javalin/src/main/java/io/javalin/core/util/Header.kt
+++ b/javalin/src/main/java/io/javalin/core/util/Header.kt
@@ -24,6 +24,7 @@ object Header {
     const val ALLOW = "Allow"
     const val AUTHORIZATION = "Authorization"
     const val CACHE_CONTROL = "Cache-Control"
+    const val CLEAR_SITE_DATA = "Clear-Site-Data"
     const val CONNECTION = "Connection"
     const val CONTENT_ENCODING = "Content-Encoding"
     const val CONTENT_DISPOSITION = "Content-Disposition"
@@ -31,8 +32,12 @@ object Header {
     const val CONTENT_LENGTH = "Content-Length"
     const val CONTENT_LOCATION = "Content-Location"
     const val CONTENT_RANGE = "Content-Range"
+    const val CONTENT_SECURITY_POLICY = "Content-Security-Policy"
     const val CONTENT_TYPE = "Content-Type"
     const val COOKIE = "Cookie"
+    const val CROSS_ORIGIN_EMBEDDER_POLICY = "Cross-Origin-Embedder-Policy"
+    const val CROSS_ORIGIN_OPENER_POLICY = "Cross-Origin-Opener-Policy"
+    const val CROSS_ORIGIN_RESOURCE_POLICY = "Cross-Origin-Resource-Policy"
     const val DATE = "Date"
     const val ETAG = "ETag"
     const val EXPECT = "Expect"
@@ -54,10 +59,12 @@ object Header {
     const val PROXY_AUTHORIZATION = "Proxy-Authorization"
     const val RANGE = "Range"
     const val REFERER = "Referer"
+    const val REFERRER_POLICY = "Referrer-Policy"
     const val RETRY_AFTER = "Retry-After"
     const val SERVER = "Server"
     const val SET_COOKIE = "Set-Cookie"
     const val SEC_WEBSOCKET_KEY = "Sec-WebSocket-Key"
+    const val STRICT_TRANSPORT_SECURITY = "Strict-Transport-Security"
     const val TE = "TE"
     const val TRAILER = "Trailer"
     const val TRANSFER_ENCODING = "Transfer-Encoding"
@@ -67,5 +74,8 @@ object Header {
     const val VIA = "Via"
     const val WARNING = "Warning"
     const val WWW_AUTHENTICATE = "WWW-Authenticate"
+    const val X_FRAME_OPTIONS = "X-Frame-Options"
+    const val X_CONTENT_TYPE_OPTIONS = "X-Content-Type-Options"
     const val X_HTTP_METHOD_OVERRIDE = "X-HTTP-Method-Override"
+    const val X_PERMITTED_CROSS_DOMAIN_POLICIES = "X-Permitted-Cross-Domain-Policies"
 }
diff --git a/javalin/src/main/java/io/javalin/core/util/Headers.kt b/javalin/src/main/java/io/javalin/core/util/Headers.kt
new file mode 100644
index 000000000..1cfd65a94
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/core/util/Headers.kt
@@ -0,0 +1,115 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2021 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin.core.util
+
+import java.time.Duration
+import java.util.Locale
+
+/**
+ * A plugin to configure arbitrary headers, with a focus on the OWASP secure headers project
+ * https://owasp.org/www-project-secure-headers/
+ */
+class Headers {
+
+    val headers = mutableMapOf<String, String>()
+
+    // Strict-Transport-Security: max-age=31536000 ; includeSubDomains
+    fun strictTransportSecurity(duration: Duration, includeSubdomains: Boolean) {
+        headers[Header.STRICT_TRANSPORT_SECURITY] = "max-age=" + duration.seconds +
+                if (includeSubdomains) { " ; includeSubDomains" } else { "" }
+    }
+
+    // X-Frame-Options: deny | sameorigin | allow-from: DOMAIN
+    enum class XFrameOptions {
+        DENY, SAMEORIGIN;
+    }
+
+    fun xFrameOptions(xFrameOptions: XFrameOptions) {
+        headers[Header.X_FRAME_OPTIONS] = xFrameOptions.name.toHttpHeaderValue()
+    }
+
+    fun xFrameOptions(domain: String) {
+        headers[Header.X_FRAME_OPTIONS] = "allow-from: $domain"
+    }
+
+    // X-Content-Type-Options: nosniff
+    fun xContentTypeOptionsNoSniff() {
+        headers[Header.X_CONTENT_TYPE_OPTIONS] = "nosniff"
+    }
+
+    // Content-Security-Policy: String... + JAVADOC
+    fun contentSecurityPolicy(contentSecurityPolicy: String) {
+        headers[Header.CONTENT_SECURITY_POLICY] = contentSecurityPolicy
+    }
+
+    // X-Permitted-Cross-Domain-Policies: none | master-only | by-content-type | by-ftp-filename | all
+    enum class CrossDomainPolicy {
+        NONE, MASTER_ONLY, BY_CONTENT_TYPE, BY_FTP_FILENAME, ALL;
+    }
+
+    fun xPermittedCrossDomainPolicies(policy: CrossDomainPolicy) {
+        headers[Header.X_PERMITTED_CROSS_DOMAIN_POLICIES] = policy.name.toHttpHeaderValue()
+    }
+
+    // Referrer-Policy: no-referrer | no-referrer-when-downgrade | origin | origin-when-cross-origin | same-origin | strict-origin | strict-origin-when-cross-origin | unsafe-url
+    enum class ReferrerPolicy {
+        NO_REFERRER, NO_REFERRER_WHEN_DOWNGRADE, ORIGIN, ORIGIN_WHEN_CROSS_ORIGIN, SAME_ORIGIN, STRICT_ORIGIN, STRICT_ORIGIN_WHEN_CROSS_ORIGIN, UNSAFE_URL;
+    }
+
+    fun referrerPolicy(policy: ReferrerPolicy) {
+        headers[Header.REFERRER_POLICY] = policy.name.toHttpHeaderValue()
+    }
+
+    // Clear-Site-Data: "cache" | "cookies" | "storage" | "executionContexts" | "*"
+    enum class ClearSiteData {
+        CACHE, COOKIES, STORAGE, EXECUTION_CONTEXTS, ANY;
+
+        fun headerValue(): String {
+            if (this == ANY) {
+                return "\"*\""
+            } else if (this == EXECUTION_CONTEXTS) {
+                return "\"executionContexts\""
+            }
+            return "\"" + name.toLowerCase(Locale.ROOT) + "\""
+        }
+    }
+
+    fun clearSiteData(vararg data: ClearSiteData) {
+        headers[Header.CLEAR_SITE_DATA] = data.joinToString(",", transform = ClearSiteData::headerValue)
+    }
+
+    // Cross-Origin-Embedder-Policy: require-corp | unsafe-none
+    enum class CrossOriginEmbedderPolicy {
+        UNSAFE_NONE, REQUIRE_CORP;
+    }
+
+    fun crossOriginEmbedderPolicy(policy: CrossOriginEmbedderPolicy) {
+        headers[Header.CROSS_ORIGIN_EMBEDDER_POLICY] = policy.name.toHttpHeaderValue()
+    }
+
+    // Cross-Origin-Opener-Policy: unsafe-none	| same-origin-allow-popups	| same-origin
+    enum class CrossOriginOpenerPolicy {
+        UNSAFE_NONE, SAME_ORIGIN_ALLOW_POPUPS, SAME_ORIGIN;
+    }
+
+    fun crossOriginOpenerPolicy(policy: CrossOriginOpenerPolicy) {
+        headers[Header.CROSS_ORIGIN_OPENER_POLICY] = policy.name.toHttpHeaderValue()
+    }
+
+    // Cross-Origin-Resource-Policy: same-site	| same-origin | cross-origin
+    enum class CrossOriginResourcePolicy {
+        SAME_SITE, SAME_ORIGIN, CROSS_ORIGIN;
+    }
+
+    fun crossOriginResourcePolicy(policy: CrossOriginResourcePolicy) {
+        headers[Header.CROSS_ORIGIN_RESOURCE_POLICY] = policy.name.toHttpHeaderValue()
+    }
+
+    private fun String.toHttpHeaderValue() : String {
+        return this.toLowerCase(Locale.ROOT).replace("_", "-")
+    }
+}
diff --git a/javalin/src/main/java/io/javalin/core/util/HeadersPlugin.kt b/javalin/src/main/java/io/javalin/core/util/HeadersPlugin.kt
new file mode 100644
index 000000000..a42bfef46
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/core/util/HeadersPlugin.kt
@@ -0,0 +1,18 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2021 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin.core.util;
+
+import io.javalin.Javalin
+import io.javalin.core.plugin.Plugin
+import io.javalin.http.Context
+
+class HeadersPlugin(private val headers: Headers) : Plugin {
+
+    override fun apply(app: Javalin) {
+        app.before { ctx: Context -> headers.headers.forEach { (t, u) -> ctx.header(t, u) } }
+    }
+}


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestHeadersPlugin.kt b/javalin/src/test/java/io/javalin/TestHeadersPlugin.kt
new file mode 100644
index 000000000..1200ed619
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/TestHeadersPlugin.kt
@@ -0,0 +1,146 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2021 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin
+
+import io.javalin.core.JavalinConfig
+import io.javalin.core.util.Header
+import io.javalin.core.util.Headers
+import io.javalin.core.util.Headers.ClearSiteData
+import io.javalin.core.util.Headers.CrossDomainPolicy
+import io.javalin.core.util.Headers.CrossOriginEmbedderPolicy
+import io.javalin.core.util.Headers.CrossOriginOpenerPolicy
+import io.javalin.core.util.Headers.CrossOriginResourcePolicy
+import io.javalin.core.util.Headers.ReferrerPolicy
+import io.javalin.core.util.Headers.XFrameOptions
+import io.javalin.core.util.HeadersPlugin
+import io.javalin.testing.TestUtil
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+import java.time.Duration
+
+class TestHeadersPlugin {
+
+    private val headers = Headers()
+
+    @Test
+    fun `header values X-Content-Type-Options NoSniff`() {
+        headers.xContentTypeOptionsNoSniff()
+        assertThat(headers.headers).containsEntry("X-Content-Type-Options", "nosniff")
+    }
+
+    @Test
+    fun `header values Strict-Transport-Security `() {
+        headers.strictTransportSecurity(Duration.ofSeconds(10), false)
+        assertThat(headers.headers).containsEntry("Strict-Transport-Security", "max-age=10")
+    }
+
+    @Test
+    fun `header values Strict-Transport-Security includeSubDomain`() {
+        headers.strictTransportSecurity(Duration.ofSeconds(10), true)
+        assertThat(headers.headers).containsEntry("Strict-Transport-Security", "max-age=10 ; includeSubDomains")
+    }
+
+    @Test
+    fun `header values X-Frame-Options deny`() {
+        headers.xFrameOptions(XFrameOptions.DENY)
+        assertThat(headers.headers).containsEntry("X-Frame-Options", "deny")
+    }
+
+    @Test
+    fun `header values X-Frame-Options same origin`() {
+        headers.xFrameOptions(XFrameOptions.SAMEORIGIN)
+        assertThat(headers.headers).containsEntry("X-Frame-Options", "sameorigin")
+    }
+
+    @Test
+    fun `header values X-Frame-Options allow-from`() {
+        headers.xFrameOptions("web.de")
+        assertThat(headers.headers).containsEntry("X-Frame-Options", "allow-from: web.de")
+    }
+
+    @Test
+    fun `header values Content-Security-Policy`() {
+        headers.contentSecurityPolicy("foo")
+        assertThat(headers.headers).containsEntry("Content-Security-Policy", "foo")
+    }
+
+    @Test
+    fun `header values X-Permitted-Cross-Domain-Policies master-only`() {
+        headers.xPermittedCrossDomainPolicies(CrossDomainPolicy.MASTER_ONLY)
+        assertThat(headers.headers).containsEntry("X-Permitted-Cross-Domain-Policies", "master-only")
+    }
+
+    @Test
+    fun `header values X-Permitted-Cross-Domain-Policies none`() {
+        headers.xPermittedCrossDomainPolicies(CrossDomainPolicy.NONE)
+        assertThat(headers.headers).containsEntry("X-Permitted-Cross-Domain-Policies", "none")
+    }
+
+    @Test
+    fun `header values X-Permitted-Cross-Domain-Policies by-content-type`() {
+        headers.xPermittedCrossDomainPolicies(CrossDomainPolicy.BY_CONTENT_TYPE)
+        assertThat(headers.headers).containsEntry("X-Permitted-Cross-Domain-Policies", "by-content-type")
+    }
+
+    @Test
+    fun `header values Referrer-Policy`() {
+        headers.referrerPolicy(ReferrerPolicy.STRICT_ORIGIN)
+        assertThat(headers.headers).containsEntry("Referrer-Policy", "strict-origin")
+    }
+
+    @Test
+    fun `header values Clear-Site-Data any`() {
+        headers.clearSiteData(ClearSiteData.ANY)
+        assertThat(headers.headers).containsEntry("Clear-Site-Data", "\"*\"")
+    }
+
+    @Test
+    fun `header values Clear-Site-Data as array`() {
+        headers.clearSiteData(ClearSiteData.ANY, ClearSiteData.EXECUTION_CONTEXTS, ClearSiteData.STORAGE)
+        assertThat(headers.headers).containsEntry("Clear-Site-Data", "\"*\",\"executionContexts\",\"storage\"")
+    }
+
+    @Test
+    fun `header values Cross-Origin-Embedder-Policy`() {
+        headers.crossOriginEmbedderPolicy(CrossOriginEmbedderPolicy.UNSAFE_NONE)
+        assertThat(headers.headers).containsEntry("Cross-Origin-Embedder-Policy", "unsafe-none")
+    }
+
+    @Test
+    fun `header values Cross-Origin-Opener-Policy`() {
+        headers.crossOriginOpenerPolicy(CrossOriginOpenerPolicy.SAME_ORIGIN_ALLOW_POPUPS)
+        assertThat(headers.headers).containsEntry("Cross-Origin-Opener-Policy", "same-origin-allow-popups")
+    }
+
+    @Test
+    fun `header values Cross-Origin-Resource-Policy`() {
+        headers.crossOriginResourcePolicy(CrossOriginResourcePolicy.SAME_SITE)
+        assertThat(headers.headers).containsEntry("Cross-Origin-Resource-Policy", "same-site")
+    }
+
+    @Test
+    fun `test plugin is registered when calling headers method`() {
+        val javalin = Javalin.create { config: JavalinConfig -> config.globalHeaders { Headers() } }
+        val retrievedPlugin: HeadersPlugin = javalin._conf.getPlugin(HeadersPlugin::class.java)
+        assertThat(retrievedPlugin).isSameAs(retrievedPlugin)
+    }
+
+    @Test
+    fun `test headers are set when startung`() {
+        val headers = Headers()
+        headers.xContentTypeOptionsNoSniff()
+        headers.clearSiteData(ClearSiteData.ANY)
+
+        val testApp = Javalin.create { config: JavalinConfig -> config.globalHeaders { headers } }
+        TestUtil.test(testApp) { app, http ->
+            app.get("/") { it.status(200) }
+            val returnedHeaders = http.get("/").headers
+            assertThat(returnedHeaders.getFirst(Header.X_CONTENT_TYPE_OPTIONS)).isEqualTo("nosniff")
+            assertThat(returnedHeaders.getFirst(Header.CLEAR_SITE_DATA)).isEqualTo("\"*\"")
+        }
+    }
+}