From b08005a86ad2ceed7d8fb8e94f93e4b71a8f65cd Mon Sep 17 00:00:00 2001 From: Nick Date: Tue, 16 Jan 2024 12:42:08 +0000 Subject: [PATCH] Add an option to send HSTS header (fix #408) --- core/src/main/resources/reference.conf | 5 +++ .../Config.scala | 7 ++++ .../HttpServer.scala | 16 ++++++-- .../Run.scala | 1 + .../resources/test-config-new-style.hocon | 5 +++ .../resources/test-config-old-style.hocon | 5 +++ .../ConfigParserSpec.scala | 3 ++ .../RoutesSpec.scala | 39 ++++++++++++++++--- .../TestUtils.scala | 4 ++ examples/config.kafka.extended.hocon | 6 +++ examples/config.kinesis.extended.hocon | 6 +++ examples/config.nsq.extended.hocon | 6 +++ examples/config.pubsub.extended.hocon | 6 +++ examples/config.sqs.extended.hocon | 6 +++ examples/config.stdout.extended.hocon | 6 +++ .../KafkaConfigSpec.scala | 1 + .../sinks/KinesisConfigSpec.scala | 1 + .../NsqConfigSpec.scala | 1 + .../ConfigSpec.scala | 1 + .../SqsConfigSpec.scala | 1 + 20 files changed, 116 insertions(+), 10 deletions(-) diff --git a/core/src/main/resources/reference.conf b/core/src/main/resources/reference.conf index be3b75e40..e4cdd9b7e 100644 --- a/core/src/main/resources/reference.conf +++ b/core/src/main/resources/reference.conf @@ -86,6 +86,11 @@ port = 443 } + hsts { + enable = false + maxAge = 365 days + } + networking { maxConnections = 1024 idleTimeout = 610 seconds diff --git a/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/Config.scala b/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/Config.scala index f4f99567a..e087e05f9 100644 --- a/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/Config.scala +++ b/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/Config.scala @@ -36,6 +36,7 @@ case class Config[+SinkConfig]( monitoring: Config.Monitoring, telemetry: Config.Telemetry, ssl: Config.SSL, + hsts: Config.HSTS, networking: Config.Networking, enableDefaultRedirect: Boolean, redirectDomains: Set[String], @@ -133,6 +134,11 @@ object Config { port: Int ) + case class HSTS( + enable: Boolean, + maxAge: FiniteDuration + ) + final case class Telemetry( // General params disable: Boolean, @@ -188,6 +194,7 @@ object Config { implicit val metrics = deriveDecoder[Metrics] implicit val monitoring = deriveDecoder[Monitoring] implicit val ssl = deriveDecoder[SSL] + implicit val hsts = deriveDecoder[HSTS] implicit val telemetry = deriveDecoder[Telemetry] implicit val networking = deriveDecoder[Networking] implicit val sinkConfig = newDecoder[SinkConfig].or(legacyDecoder[SinkConfig]) diff --git a/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/HttpServer.scala b/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/HttpServer.scala index 79fffb74d..e150119db 100644 --- a/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/HttpServer.scala +++ b/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/HttpServer.scala @@ -15,10 +15,11 @@ import cats.implicits._ import com.avast.datadog4s.api.Tag import com.avast.datadog4s.extension.http4s.DatadogMetricsOps import com.avast.datadog4s.{StatsDMetricFactory, StatsDMetricFactoryConfig} -import org.http4s.HttpRoutes +import org.http4s.{HttpApp, HttpRoutes} import org.http4s.blaze.server.BlazeServerBuilder +import org.http4s.headers.`Strict-Transport-Security` import org.http4s.server.Server -import org.http4s.server.middleware.Metrics +import org.http4s.server.middleware.{HSTS, Metrics} import org.typelevel.log4cats.Logger import org.typelevel.log4cats.slf4j.Slf4jLogger @@ -33,12 +34,13 @@ object HttpServer { routes: HttpRoutes[F], port: Int, secure: Boolean, + hsts: Config.HSTS, networking: Config.Networking, metricsConfig: Config.Metrics ): Resource[F, Server] = for { withMetricsMiddleware <- createMetricsMiddleware(routes, metricsConfig) - server <- buildBlazeServer[F](withMetricsMiddleware, port, secure, networking) + server <- buildBlazeServer[F](withMetricsMiddleware, port, secure, hsts, networking) } yield server private def createMetricsMiddleware[F[_]: Async]( @@ -60,16 +62,22 @@ object HttpServer { StatsDMetricFactoryConfig(Some(metricsConfig.statsd.prefix), server, defaultTags = tags) } + private[core] def hstsMiddleware[F[_]: Async](hsts: Config.HSTS, routes: HttpApp[F]): HttpApp[F] = + if (hsts.enable) + HSTS(routes, `Strict-Transport-Security`.unsafeFromDuration(hsts.maxAge)) + else routes + private def buildBlazeServer[F[_]: Async]( routes: HttpRoutes[F], port: Int, secure: Boolean, + hsts: Config.HSTS, networking: Config.Networking ): Resource[F, Server] = Resource.eval(Logger[F].info("Building blaze server")) >> BlazeServerBuilder[F] .bindSocketAddress(new InetSocketAddress(port)) - .withHttpApp(routes.orNotFound) + .withHttpApp(hstsMiddleware(hsts, routes.orNotFound)) .withIdleTimeout(networking.idleTimeout) .withMaxConnections(networking.maxConnections) .cond(secure, _.withSslContext(SSLContext.getDefault)) diff --git a/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/Run.scala b/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/Run.scala index 78da39188..7fbbb7dab 100644 --- a/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/Run.scala +++ b/core/src/main/scala/com.snowplowanalytics.snowplow.collector.core/Run.scala @@ -102,6 +102,7 @@ object Run { ).value, if (config.ssl.enable) config.ssl.port else config.port, config.ssl.enable, + config.hsts, config.networking, config.monitoring.metrics ) diff --git a/core/src/test/resources/test-config-new-style.hocon b/core/src/test/resources/test-config-new-style.hocon index 06b3ba962..22454dd83 100644 --- a/core/src/test/resources/test-config-new-style.hocon +++ b/core/src/test/resources/test-config-new-style.hocon @@ -33,4 +33,9 @@ collector { ssl { enable = true } + + hsts { + enable = true + maxAge = 180 days + } } diff --git a/core/src/test/resources/test-config-old-style.hocon b/core/src/test/resources/test-config-old-style.hocon index 8d2e06598..1d9104e32 100644 --- a/core/src/test/resources/test-config-old-style.hocon +++ b/core/src/test/resources/test-config-old-style.hocon @@ -21,4 +21,9 @@ collector { ssl { enable = true } + + hsts { + enable = true + maxAge = 180 days + } } diff --git a/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/ConfigParserSpec.scala b/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/ConfigParserSpec.scala index 3f25aeea5..dd3c7d0cb 100644 --- a/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/ConfigParserSpec.scala +++ b/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/ConfigParserSpec.scala @@ -7,6 +7,8 @@ import cats.effect.testing.specs2.CatsEffect import com.snowplowanalytics.snowplow.collector.core.Config.Buffer import io.circe.generic.semiauto._ +import scala.concurrent.duration.DurationInt + class ConfigParserSpec extends Specification with CatsEffect { "Loading the configuration" should { @@ -53,6 +55,7 @@ class ConfigParserSpec extends Specification with CatsEffect { paths = Map.empty[String, String], streams = expectedStreams, ssl = TestUtils.testConfig.ssl.copy(enable = true), + hsts = TestUtils.testConfig.hsts.copy(enable = true, 180.days), license = Config.License(false) ) diff --git a/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/RoutesSpec.scala b/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/RoutesSpec.scala index 9edcd5fc5..d75249b8b 100644 --- a/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/RoutesSpec.scala +++ b/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/RoutesSpec.scala @@ -1,18 +1,19 @@ package com.snowplowanalytics.snowplow.collector.core -import scala.collection.mutable.ListBuffer +import cats.data.NonEmptyList +import scala.collection.mutable.ListBuffer import org.specs2.mutable.Specification - import cats.effect.IO import cats.effect.unsafe.implicits.global - import org.http4s.implicits._ import org.http4s._ import org.http4s.headers._ import org.http4s.Status._ - import fs2.{Stream, text} +import org.typelevel.ci.CIString + +import scala.concurrent.duration.DurationInt class RoutesSpec extends Specification { @@ -67,12 +68,14 @@ class RoutesSpec extends Specification { def createTestServices( enabledDefaultRedirect: Boolean = true, enableRootResponse: Boolean = false, - enableCrossdomainTracking: Boolean = false + enableCrossdomainTracking: Boolean = false, + enableHsts: Boolean = false ) = { val service = new TestService() val routes = new Routes(enabledDefaultRedirect, enableRootResponse, enableCrossdomainTracking, service).value.orNotFound - (service, routes) + val routesWithHsts = HttpServer.hstsMiddleware(Config.HSTS(enableHsts, 180.days), routes) + (service, routesWithHsts) } "The collector route" should { @@ -97,6 +100,30 @@ class RoutesSpec extends Specification { test(uri"/p3/p4") } + "respond with an HSTS header when HSTS is enabled" in { + val (_, routesHstsOn) = createTestServices(enableHsts = true) + val (_, routesHstsOff) = createTestServices(enableHsts = false) + def testHstsOn(uri: Uri) = { + val request = Request[IO](method = Method.GET, uri = uri) + val response = routesHstsOn.run(request).unsafeRunSync() + response.headers.get(CIString("Strict-Transport-Security")) shouldEqual + Some( + NonEmptyList.of(Header.Raw(CIString("Strict-Transport-Security"), "max-age=15552000; includeSubDomains")) + ) + } + def testHstsOff(uri: Uri) = { + val request = Request[IO](method = Method.GET, uri = uri) + val response = routesHstsOff.run(request).unsafeRunSync() + response.headers.get(CIString("Strict-Transport-Security")) shouldEqual None + } + testHstsOn(uri"/i") + testHstsOn(uri"/health") + testHstsOn(uri"/give-me-404") + testHstsOff(uri"/i") + testHstsOff(uri"/health") + testHstsOff(uri"/give-me-404") + } + "respond to the post cookie route with the cookie response" in { val (collectorService, routes) = createTestServices() diff --git a/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/TestUtils.scala b/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/TestUtils.scala index 5db8643cf..e880d00c9 100644 --- a/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/TestUtils.scala +++ b/core/src/test/scala/com.snowplowanalytics.snowplow.collector.core/TestUtils.scala @@ -113,6 +113,10 @@ object TestUtils { false, 443 ), + hsts = HSTS( + false, + 365.days + ), networking = Networking( 1024, 610.seconds diff --git a/examples/config.kafka.extended.hocon b/examples/config.kafka.extended.hocon index 217d0a5af..095118c22 100644 --- a/examples/config.kafka.extended.hocon +++ b/examples/config.kafka.extended.hocon @@ -32,6 +32,12 @@ collector { port = 443 } + # optional HSTS configuration + hsts { + enable = false + maxAge = 365 days + } + # The collector responds with a cookie to requests with a path that matches the 'vendor/version' protocol. # The expected values are: # - com.snowplowanalytics.snowplow/tp2 for Tracker Protocol 2 diff --git a/examples/config.kinesis.extended.hocon b/examples/config.kinesis.extended.hocon index c2820c129..0efd17d72 100644 --- a/examples/config.kinesis.extended.hocon +++ b/examples/config.kinesis.extended.hocon @@ -32,6 +32,12 @@ collector { port = 443 } + # optional HSTS configuration + hsts { + enable = false + maxAge = 365 days + } + # The collector responds with a cookie to requests with a path that matches the 'vendor/version' protocol. # The expected values are: # - com.snowplowanalytics.snowplow/tp2 for Tracker Protocol 2 diff --git a/examples/config.nsq.extended.hocon b/examples/config.nsq.extended.hocon index 804a6d2f5..f2925ad6d 100644 --- a/examples/config.nsq.extended.hocon +++ b/examples/config.nsq.extended.hocon @@ -32,6 +32,12 @@ collector { port = 443 } + # optional HSTS configuration + hsts { + enable = false + maxAge = 365 days + } + # The collector responds with a cookie to requests with a path that matches the 'vendor/version' protocol. # The expected values are: # - com.snowplowanalytics.snowplow/tp2 for Tracker Protocol 2 diff --git a/examples/config.pubsub.extended.hocon b/examples/config.pubsub.extended.hocon index 3b24f30d5..0d4fcc439 100644 --- a/examples/config.pubsub.extended.hocon +++ b/examples/config.pubsub.extended.hocon @@ -32,6 +32,12 @@ collector { port = 443 } + # optional HSTS configuration + hsts { + enable = false + maxAge = 365 days + } + # The collector responds with a cookie to requests with a path that matches the 'vendor/version' protocol. # The expected values are: # - com.snowplowanalytics.snowplow/tp2 for Tracker Protocol 2 diff --git a/examples/config.sqs.extended.hocon b/examples/config.sqs.extended.hocon index 0ebfbce0a..65a58db79 100644 --- a/examples/config.sqs.extended.hocon +++ b/examples/config.sqs.extended.hocon @@ -27,6 +27,12 @@ collector { port = 443 } + # optional HSTS configuration + hsts { + enable = false + maxAge = 365 days + } + # The collector responds with a cookie to requests with a path that matches the 'vendor/version' protocol. # The expected values are: # - com.snowplowanalytics.snowplow/tp2 for Tracker Protocol 2 diff --git a/examples/config.stdout.extended.hocon b/examples/config.stdout.extended.hocon index a29437811..42593a3d7 100644 --- a/examples/config.stdout.extended.hocon +++ b/examples/config.stdout.extended.hocon @@ -32,6 +32,12 @@ collector { port = 443 } + # optional HSTS configuration + hsts { + enable = false + maxAge = 365 days + } + # The collector responds with a cookie to requests with a path that matches the 'vendor/version' protocol. # The expected values are: # - com.snowplowanalytics.snowplow/tp2 for Tracker Protocol 2 diff --git a/kafka/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/KafkaConfigSpec.scala b/kafka/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/KafkaConfigSpec.scala index 6dc8ea4db..55125c839 100644 --- a/kafka/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/KafkaConfigSpec.scala +++ b/kafka/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/KafkaConfigSpec.scala @@ -108,6 +108,7 @@ object KafkaConfigSpec { ) ), ssl = Config.SSL(enable = false, redirect = false, port = 443), + hsts = Config.HSTS(enable = false, maxAge = 365.days), enableDefaultRedirect = false, redirectDomains = Set.empty, preTerminationPeriod = 10.seconds, diff --git a/kinesis/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/sinks/KinesisConfigSpec.scala b/kinesis/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/sinks/KinesisConfigSpec.scala index ce5eec188..b59a6a532 100644 --- a/kinesis/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/sinks/KinesisConfigSpec.scala +++ b/kinesis/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/sinks/KinesisConfigSpec.scala @@ -118,6 +118,7 @@ object KinesisConfigSpec { ) ), ssl = Config.SSL(enable = false, redirect = false, port = 443), + hsts = Config.HSTS(enable = false, maxAge = 365.days), enableDefaultRedirect = false, redirectDomains = Set.empty, preTerminationPeriod = 10.seconds, diff --git a/nsq/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/NsqConfigSpec.scala b/nsq/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/NsqConfigSpec.scala index 8db7091ae..dcc615105 100644 --- a/nsq/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/NsqConfigSpec.scala +++ b/nsq/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/NsqConfigSpec.scala @@ -107,6 +107,7 @@ object NsqConfigSpec { ) ), ssl = Config.SSL(enable = false, redirect = false, port = 443), + hsts = Config.HSTS(enable = false, maxAge = 365.days), enableDefaultRedirect = false, redirectDomains = Set.empty, preTerminationPeriod = 10.seconds, diff --git a/pubsub/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/ConfigSpec.scala b/pubsub/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/ConfigSpec.scala index ab54e0175..4431ec1bb 100644 --- a/pubsub/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/ConfigSpec.scala +++ b/pubsub/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/ConfigSpec.scala @@ -107,6 +107,7 @@ object ConfigSpec { ) ), ssl = Config.SSL(enable = false, redirect = false, port = 443), + hsts = Config.HSTS(enable = false, maxAge = 365.days), enableDefaultRedirect = false, redirectDomains = Set.empty, preTerminationPeriod = 10.seconds, diff --git a/sqs/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/SqsConfigSpec.scala b/sqs/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/SqsConfigSpec.scala index e71089b2e..6f6cc5e2d 100644 --- a/sqs/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/SqsConfigSpec.scala +++ b/sqs/src/test/scala/com.snowplowanalytics.snowplow.collectors.scalastream/SqsConfigSpec.scala @@ -108,6 +108,7 @@ object SqsConfigSpec { ) ), ssl = Config.SSL(enable = false, redirect = false, port = 443), + hsts = Config.HSTS(enable = false, maxAge = 365.days), enableDefaultRedirect = false, redirectDomains = Set.empty, preTerminationPeriod = 10.seconds,