Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP spec conformance during CI #3169

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
73 changes: 73 additions & 0 deletions .github/workflows/http-conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: HTTP Conformance

on:
pull_request:
branches: ["**"]
push:
branches: ["**"]
tags: [v*]

env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JDK_JAVA_OPTIONS: "-Xms4G -Xmx8G -XX:+UseG1GC -Xss10M -XX:ReservedCodeCacheSize=1G -XX:NonProfiledCodeHeapSize=512m -Dfile.encoding=UTF-8"
SBT_OPTS: "-Xms4G -Xmx8G -XX:+UseG1GC -Xss10M -XX:ReservedCodeCacheSize=1G -XX:NonProfiledCodeHeapSize=512m -Dfile.encoding=UTF-8"

jobs:
build:
name: Build and Test
strategy:
matrix:
os: [ubuntu-latest]
scala: [2.12.19, 2.13.14, 3.3.3]
java:
- graal_graalvm@17
- graal_graalvm@21
- temurin@17
- temurin@21
runs-on: ${{ matrix.os }}
timeout-minutes: 60

steps:
- name: Checkout current branch (full)
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup GraalVM (graal_graalvm@17)
if: matrix.java == 'graal_graalvm@17'
uses: graalvm/setup-graalvm@v1
with:
java-version: 17
distribution: graalvm
components: native-image
github-token: ${{ secrets.GITHUB_TOKEN }}
cache: sbt

- name: Setup GraalVM (graal_graalvm@21)
if: matrix.java == 'graal_graalvm@21'
uses: graalvm/setup-graalvm@v1
with:
java-version: 21
distribution: graalvm
components: native-image
github-token: ${{ secrets.GITHUB_TOKEN }}
cache: sbt

- name: Setup Java (temurin@17)
if: matrix.java == 'temurin@17'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: sbt

- name: Setup Java (temurin@21)
if: matrix.java == 'temurin@21'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: sbt

- name: Run HTTP Conformance Tests
run: sbt "project zioHttpJVM" "testOnly zio.http.ConformanceSpec zio.http.ConformanceE2ESpec"
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,19 @@ private[zio] final case class ServerInboundHandler(
)
releaseRequest()
} else {
val req = makeZioRequest(ctx, jReq)
val exit = handler(req)
if (attemptImmediateWrite(ctx, req.method, exit)) {
val req = makeZioRequest(ctx, jReq)
if (!validateHostHeader(req)) {
attemptFastWrite(ctx, req.method, Response.status(Status.BadRequest))
releaseRequest()
} else {
writeResponse(ctx, runtime, exit, req)(releaseRequest)

val exit = handler(req)
if (attemptImmediateWrite(ctx, req.method, exit)) {
releaseRequest()
} else {
writeResponse(ctx, runtime, exit, req)(releaseRequest)

}
}
}
} finally {
Expand All @@ -108,8 +115,35 @@ private[zio] final case class ServerInboundHandler(

}

private def validateHostHeader(req: Request): Boolean = {
val host = req.headers.get("Host").getOrElse(null)
if (host != null) {
val parts = host.split(":")
val hostname = parts(0)
val isValidHost = validateHostname(hostname)
val isValidPort = parts.length == 1 || (parts.length == 2 && parts(1).forall(_.isDigit))
val isValid = isValidHost && isValidPort
isValid
} else {
false
}
}

// Validate a regular hostname (based on RFC 1035)
private def validateHostname(hostname: String): Boolean = {
if (hostname.isEmpty || hostname.contains("_")) {
return false
}
val labels = hostname.split("\\.")
if (labels.exists(label => label.isEmpty || label.length > 63 || label.startsWith("-") || label.endsWith("-"))) {
return false
}
hostname.forall(c => c.isLetterOrDigit || c == '.' || c == '-') && hostname.length <= 253
}

override def exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable): Unit =
cause match {

case ioe: IOException if {
val msg = ioe.getMessage
(msg ne null) && msg.contains("Connection reset")
Expand Down Expand Up @@ -262,7 +296,6 @@ private[zio] final case class ServerInboundHandler(
remoteCertificate = clientCert,
)
}

}

/*
Expand Down
51 changes: 51 additions & 0 deletions zio-http/jvm/src/test/scala/zio/http/ConformanceE2ESpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package zio.http

import zio._
import zio.test.Assertion._
import zio.test.TestAspect._
import zio.test._

import zio.http._
import zio.http.internal.{DynamicServer, RoutesRunnableSpec}
import zio.http.netty.NettyConfig

object ConformanceE2ESpec extends RoutesRunnableSpec {

private val port = 8080
private val MaxSize = 1024 * 10
val configApp = Server.Config.default
.requestDecompression(true)
.disableRequestStreaming(MaxSize)
.port(port)
.responseCompression()

private val app = serve

def conformanceSpec = suite("ConformanceE2ESpec")(
test("should return 400 Bad Request if Host header is missing") {
val routes = Handler.ok.toRoutes

val res = routes.deploy.status.run(path = Path.root, headers = Headers(Header.Host("%%%%invalid%%%%")))
assertZIO(res)(equalTo(Status.BadRequest))
},
test("should return 200 OK if Host header is present") {
val routes = Handler.ok.toRoutes

val res = routes.deploy.status.run(path = Path.root, headers = Headers(Header.Host("localhost")))
assertZIO(res)(equalTo(Status.Ok))
},
)

override def spec =
suite("ConformanceE2ESpec") {
val spec = conformanceSpec
suite("app without request streaming") { app.as(List(spec)) }
}.provideShared(
DynamicServer.live,
ZLayer.succeed(configApp),
Server.customized,
Client.default,
ZLayer.succeed(NettyConfig.default),
) @@ sequential @@ withLiveClock

}
Loading
Loading