Skip to content

Commit

Permalink
Add an option to send HSTS header (fix #148)
Browse files Browse the repository at this point in the history
  • Loading branch information
stanch committed Jan 19, 2024
1 parent 7226e02 commit f55844b
Show file tree
Hide file tree
Showing 6 changed files with 105 additions and 40 deletions.
4 changes: 4 additions & 0 deletions src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"type": "fixed"
"size": 4
}
"hsts": {
"enable": false
"maxAge": "365 days"
}
}

"database" {
Expand Down
18 changes: 12 additions & 6 deletions src/main/scala/com/snowplowanalytics/iglu/server/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,18 @@ 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._
import pureconfig.module.http4s._
import migrations.MigrateFrom

import scala.concurrent.duration.FiniteDuration

import generated.BuildInfo.version

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 13 additions & 15 deletions src/main/scala/com/snowplowanalytics/iglu/server/Server.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -187,7 +184,8 @@ object Server {
cache,
config.swagger,
blocker,
isHealthy
isHealthy,
config.repoServer.hsts
)
)
.withIdleTimeout(config.repoServer.idleTimeout.getOrElse(Http4sDefaults.IdleTimeout))
Expand Down
4 changes: 4 additions & 0 deletions src/test/resources/valid-dummy-config.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions src/test/scala/com/snowplowanalytics/iglu/server/ConfigSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
75 changes: 61 additions & 14 deletions src/test/scala/com/snowplowanalytics/iglu/server/ServerSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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._
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)

Expand Down

0 comments on commit f55844b

Please sign in to comment.