From f55844b41ecc235cca54b43dd0925702c0a44776 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 15 Jan 2024 12:15:41 +0000 Subject: [PATCH] Add an option to send HSTS header (fix #148) --- src/main/resources/application.conf | 4 + .../iglu/server/Config.scala | 18 +++-- .../iglu/server/Server.scala | 28 ++++--- src/test/resources/valid-dummy-config.conf | 4 + .../iglu/server/ConfigSpec.scala | 16 ++-- .../iglu/server/ServerSpec.scala | 75 +++++++++++++++---- 6 files changed, 105 insertions(+), 40 deletions(-) diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index ee21947..5ee411e 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -15,6 +15,10 @@ "type": "fixed" "size": 4 } + "hsts": { + "enable": false + "maxAge": "365 days" + } } "database" { diff --git a/src/main/scala/com/snowplowanalytics/iglu/server/Config.scala b/src/main/scala/com/snowplowanalytics/iglu/server/Config.scala index f52a1ef..c98e455 100644 --- a/src/main/scala/com/snowplowanalytics/iglu/server/Config.scala +++ b/src/main/scala/com/snowplowanalytics/iglu/server/Config.scala @@ -12,15 +12,11 @@ package com.snowplowanalytics.iglu.server import java.nio.file.Path import java.util.UUID - import cats.implicits._ - import com.monovore.decline._ - import io.circe.{Encoder, Json, JsonObject} import io.circe.syntax._ import io.circe.generic.semiauto._ - import pureconfig._ import pureconfig.generic.ProductHint import pureconfig.generic.semiauto._ @@ -28,7 +24,6 @@ import pureconfig.module.http4s._ import migrations.MigrateFrom import scala.concurrent.duration.FiniteDuration - import generated.BuildInfo.version /** @@ -217,12 +212,21 @@ object Config { port: Int, idleTimeout: Option[FiniteDuration], maxConnections: Option[Int], - threadPool: ThreadPool + threadPool: ThreadPool, + hsts: Config.Hsts ) implicit val httpConfigCirceEncoder: Encoder[Http] = deriveEncoder[Http] + case class Hsts( + enable: Boolean, + maxAge: FiniteDuration + ) + + implicit val hstsConfigCirceEncoder: Encoder[Hsts] = + deriveEncoder[Hsts] + implicit val pureWebhookReader: ConfigReader[Webhook] = ConfigReader.fromCursor { cur => for { objCur <- cur.asObjectCursor @@ -258,6 +262,8 @@ object Config { implicit val pureHttpReader: ConfigReader[Http] = deriveReader[Http] + implicit val pureHstsReader: ConfigReader[Hsts] = deriveReader[Hsts] + implicit val pureWebhooksReader: ConfigReader[List[Webhook]] = ConfigReader.fromCursor { cur => for { objCur <- cur.asObjectCursor diff --git a/src/main/scala/com/snowplowanalytics/iglu/server/Server.scala b/src/main/scala/com/snowplowanalytics/iglu/server/Server.scala index 256ab36..5930818 100644 --- a/src/main/scala/com/snowplowanalytics/iglu/server/Server.scala +++ b/src/main/scala/com/snowplowanalytics/iglu/server/Server.scala @@ -12,47 +12,37 @@ package com.snowplowanalytics.iglu.server import java.util.concurrent.{ExecutorService, Executors} import java.util.UUID - import scala.concurrent.duration._ import scala.concurrent.ExecutionContext - import cats.data.Kleisli import cats.effect.{Blocker, ContextShift, ExitCase, ExitCode, IO, Resource, Sync, Timer} import cats.effect.concurrent.Ref - import io.circe.syntax._ - import org.typelevel.log4cats.slf4j.Slf4jLogger - import fs2.Stream import fs2.concurrent.SignallingRef - import org.http4s.{Headers, HttpApp, HttpRoutes, MediaType, Method, Request, Response, Status} -import org.http4s.headers.`Content-Type` +import org.http4s.headers.{`Content-Type`, `Strict-Transport-Security`} import org.http4s.client.blaze.BlazeClientBuilder import org.http4s.server.Router import org.http4s.server.blaze.BlazeServerBuilder -import org.http4s.server.middleware.{AutoSlash, CORS, Logger} +import org.http4s.server.middleware.{AutoSlash, CORS, HSTS, Logger} import org.http4s.syntax.string._ import org.http4s.server.{defaults => Http4sDefaults} import org.http4s.util.{CaseInsensitiveString => CIString} - import org.http4s.rho.{AuthedContext, RhoMiddleware} import org.http4s.rho.bits.PathAST.{PathMatch, TypedPath} import org.http4s.rho.swagger.syntax.{io => ioSwagger} import org.http4s.rho.swagger.models.{ApiKeyAuthDefinition, In, Info, SecurityRequirement} import org.http4s.rho.swagger.SwaggerMetadata - import doobie.implicits._ import doobie.util.transactor.Transactor - import com.snowplowanalytics.iglu.server.migrations.{Bootstrap, MigrateFrom} import com.snowplowanalytics.iglu.server.codecs.Swagger import com.snowplowanalytics.iglu.server.middleware.{BadRequestHandler, CachingMiddleware} import com.snowplowanalytics.iglu.server.model.{IgluResponse, Permission} import com.snowplowanalytics.iglu.server.storage.Storage import com.snowplowanalytics.iglu.server.service._ - import generated.BuildInfo.version object Server { @@ -102,13 +92,20 @@ object Server { cache: CachingMiddleware.ResponseCache[IO], swaggerConfig: Config.Swagger, blocker: Blocker, - isHealthy: IO[Boolean] + isHealthy: IO[Boolean], + hsts: Config.Hsts )(implicit cs: ContextShift[IO]): HttpApp[IO] = { val serverRoutes = httpRoutes(storage, superKey, debug, patchesAllowed, webhook, cache, swaggerConfig, blocker, isHealthy) - Kleisli[IO, Request[IO], Response[IO]](req => Router(serverRoutes: _*).run(req).getOrElse(NotFound)) + val server = Kleisli[IO, Request[IO], Response[IO]](req => Router(serverRoutes: _*).run(req).getOrElse(NotFound)) + hstsMiddleware(hsts)(server) } + def hstsMiddleware(hsts: Config.Hsts): HttpApp[IO] => HttpApp[IO] = + if (hsts.enable) + HSTS(_, `Strict-Transport-Security`.unsafeFromDuration(hsts.maxAge)) + else identity + def httpRoutes( storage: Storage[IO], superKey: Option[UUID], @@ -187,7 +184,8 @@ object Server { cache, config.swagger, blocker, - isHealthy + isHealthy, + config.repoServer.hsts ) ) .withIdleTimeout(config.repoServer.idleTimeout.getOrElse(Http4sDefaults.IdleTimeout)) diff --git a/src/test/resources/valid-dummy-config.conf b/src/test/resources/valid-dummy-config.conf index e97840b..0d135fd 100644 --- a/src/test/resources/valid-dummy-config.conf +++ b/src/test/resources/valid-dummy-config.conf @@ -17,6 +17,10 @@ repoServer { type = "fixed" size = 2 } + hsts { + enable = true + maxAge = "365 days" + } } # 'postgres' contains configuration options for the postgre instance the server is using diff --git a/src/test/scala/com/snowplowanalytics/iglu/server/ConfigSpec.scala b/src/test/scala/com/snowplowanalytics/iglu/server/ConfigSpec.scala index 792ccba..6fb500f 100644 --- a/src/test/scala/com/snowplowanalytics/iglu/server/ConfigSpec.scala +++ b/src/test/scala/com/snowplowanalytics/iglu/server/ConfigSpec.scala @@ -31,6 +31,8 @@ class ConfigSpec extends org.specs2.Specification { parse minimal config with dummy DB from file $e5 """ + val noHsts = Config.Hsts(enable = false, maxAge = 365.days) + def e1 = { val input = "--config foo.hocon" val expected = Config.ServerCommand.Run(Some(Paths.get("foo.hocon"))) @@ -67,7 +69,7 @@ class ConfigSpec extends org.specs2.Specification { pool, false ), - Config.Http("0.0.0.0", 8080, Some(10.seconds), None, Config.ThreadPool.Global), + Config.Http("0.0.0.0", 8080, Some(10.seconds), None, Config.ThreadPool.Global, noHsts), true, true, List( @@ -95,7 +97,7 @@ class ConfigSpec extends org.specs2.Specification { val expected = Config( Config.StorageConfig.Dummy, - Config.Http("0.0.0.0", 8080, None, None, Config.ThreadPool.Fixed(2)), + Config.Http("0.0.0.0", 8080, None, None, Config.ThreadPool.Fixed(2), Config.Hsts(true, 365.days)), true, false, Nil, @@ -123,7 +125,7 @@ class ConfigSpec extends org.specs2.Specification { Config.StorageConfig.ConnectionPool.NoPool(Config.ThreadPool.Fixed(2)), true ), - Config.Http("0.0.0.0", 8080, None, None, Config.ThreadPool.Global), + Config.Http("0.0.0.0", 8080, None, None, Config.ThreadPool.Global, noHsts), true, true, List( @@ -163,7 +165,11 @@ class ConfigSpec extends org.specs2.Specification { "port" : 8080, "idleTimeout": null, "maxConnections": null, - "threadPool": "global" + "threadPool": "global", + "hsts": { + "enable": false, + "maxAge": "365 days" + } }, "debug" : true, "patchesAllowed" : true, @@ -218,7 +224,7 @@ class ConfigSpec extends org.specs2.Specification { pool, true ), - Config.Http("0.0.0.0", 8080, None, None, Config.ThreadPool.Fixed(4)), + Config.Http("0.0.0.0", 8080, None, None, Config.ThreadPool.Fixed(4), noHsts), false, false, Nil, diff --git a/src/test/scala/com/snowplowanalytics/iglu/server/ServerSpec.scala b/src/test/scala/com/snowplowanalytics/iglu/server/ServerSpec.scala index e1d23f3..e533af7 100644 --- a/src/test/scala/com/snowplowanalytics/iglu/server/ServerSpec.scala +++ b/src/test/scala/com/snowplowanalytics/iglu/server/ServerSpec.scala @@ -20,6 +20,7 @@ import org.http4s._ import org.http4s.implicits._ import org.http4s.circe._ import org.http4s.client.blaze.BlazeClientBuilder +import org.http4s.headers.`Strict-Transport-Security` import org.specs2.Specification @@ -42,6 +43,7 @@ class ServerSpec extends Specification { Return 404 for unknown endpoint $e2 Create a new private schema via PUT, return it with proper apikey, hide for no apikey $e3 Create a new public schema via POST, get it from /schemas, delete it $e4 + Return an HSTS header when configured to do so $e5 ${action(System.clearProperty("org.slf4j.simpleLogger.defaultLogLevel"))} """ import ServerSpec._ @@ -136,6 +138,46 @@ class ServerSpec extends Specification { execute(action) must beEqualTo(expected) } + + def e5 = { + val schema = SelfDescribingSchema[Json]( + SchemaMap("com.acme", "first", "jsonschema", SchemaVer.Full(1, 0, 0)), + json"""{"properties": {}}""" + ).normalize + + val reqs = List( + Request[IO](Method.POST, uri"/".withQueryParam("isPublic", "true")) + .withEntity(schema) + .withHeaders(Header("apikey", InMemory.DummySuperKey.toString)), + Request[IO](Method.DELETE, uri"/com.acme/first/jsonschema/1-0-0") + .withHeaders(Header("apikey", InMemory.DummySuperKey.toString)), + Request[IO](Method.GET, uri"/"), + Request[IO](Method.GET, uri"/nonExistingEndpoint") + ) + + val expected = Some( + List( + `Strict-Transport-Security`.unsafeFromLong(180 * 24 * 3600, includeSubDomains = true), + `Strict-Transport-Security`.unsafeFromLong(180 * 24 * 3600, includeSubDomains = true), + `Strict-Transport-Security`.unsafeFromLong(180 * 24 * 3600, includeSubDomains = true), + `Strict-Transport-Security`.unsafeFromLong(180 * 24 * 3600, includeSubDomains = true) + ) + ) + + def action(hsts: Config.Hsts) = + for { + responses <- ServerSpec.executeRequests(reqs, hsts) + results = responses.traverse(res => res.headers.get(`Strict-Transport-Security`)) + } yield results + + val hstsOn = Config.Hsts(enable = true, 180.days) + val hstsOff = Config.Hsts(enable = false, 365.days) + + val on = execute(action(hstsOn), hstsOn) must beEqualTo(expected) + val off = execute(action(hstsOff), hstsOff) must beEqualTo(None) + + on.and(off) + } } object ServerSpec { @@ -147,7 +189,7 @@ object ServerSpec { .StorageConfig .ConnectionPool .Hikari(None, None, None, None, Config.ThreadPool.Cached, Config.ThreadPool.Cached) - val httpConfig = Config.Http("0.0.0.0", 8080, None, None, Config.ThreadPool.Cached) + def httpConfig(hsts: Config.Hsts) = Config.Http("0.0.0.0", 8080, None, None, Config.ThreadPool.Cached, hsts) val storageConfig = Config .StorageConfig @@ -162,29 +204,34 @@ object ServerSpec { dbPoolConfig, true ) - val config = Config(storageConfig, httpConfig, false, true, Nil, Config.Swagger(""), None, 10.seconds, false) + def config(hsts: Config.Hsts) = + Config(storageConfig, httpConfig(hsts), false, true, Nil, Config.Swagger(""), None, 10.seconds, false) - private val runServer = Server.buildServer(config, IO.pure(true)).flatMap(_.resource) - private val client = BlazeClientBuilder[IO](global).resource - private val env = client <* runServer + private def runServer(hsts: Config.Hsts) = Server.buildServer(config(hsts), IO.pure(true)).flatMap(_.resource) + private val client = BlazeClientBuilder[IO](global).resource + private def env(hsts: Config.Hsts) = client <* runServer(hsts) /** Execute requests against fresh server (only one execution per test is allowed) */ - def executeRequests(requests: List[Request[IO]]): IO[List[Response[IO]]] = { + def executeRequests( + requests: List[Request[IO]], + hsts: Config.Hsts = Config.Hsts(false, 365.days) + ): IO[List[Response[IO]]] = { val r = requests.map { r => if (r.uri.host.isDefined) r else r.withUri(uri"http://localhost:8080/api/schemas".addPath(r.uri.path)) } - env.use(client => r.traverse(client.run(_).use(IO.pure))) + env(hsts).use(client => r.traverse(client.run(_).use(IO.pure))) } - val specification = Resource.make { - Storage.initialize[IO](storageConfig).use(s => s.asInstanceOf[Postgres[IO]].drop) *> - Server.setup(ServerSpec.config, None).void *> - Storage.initialize[IO](storageConfig).use(_.addPermission(InMemory.DummySuperKey, Permission.Super)) - }(_ => Storage.initialize[IO](storageConfig).use(s => s.asInstanceOf[Postgres[IO]].drop)) + def specification(hsts: Config.Hsts) = + Resource.make { + Storage.initialize[IO](storageConfig).use(s => s.asInstanceOf[Postgres[IO]].drop) *> + Server.setup(ServerSpec.config(hsts), None).void *> + Storage.initialize[IO](storageConfig).use(_.addPermission(InMemory.DummySuperKey, Permission.Super)) + }(_ => Storage.initialize[IO](storageConfig).use(s => s.asInstanceOf[Postgres[IO]].drop)) - def execute[A](action: IO[A]): A = - specification.use(_ => action).unsafeRunSync() + def execute[A](action: IO[A], hsts: Config.Hsts = Config.Hsts(false, 365.days)): A = + specification(hsts).use(_ => action).unsafeRunSync() case class TestResponse[E](status: Int, body: E)