At StackHawk, our application scanner HawkScan has to handle interactions with a variety of different applications and their security strategies. In order to successfully scan different applications, we have written many tests around various web authentication types. For cookie based authentication, this involves the setting up and handling of various HTTP requests and responses, including manipulating HTTP headers for setting and getting authentication cookies. Our scanner uses Kotlin and the excellent Ktor for our testing, along with JUnit 5. Recently, we had to test an external cookie authentication case and wanted to walk through how easy Ktor made it. Plus, we wanted to share some of our StackHawk helper functions.
External Cookie Auth
For external cookie authentication, a request is first made to the application, which redirects to a different, "external" host and port. This external application then provides the cookie via the Set-cookie header.
Test Setup
Let's say we want to verify a basic happy path for external cookie auth in a test. This test would need to make the initial request against the app, follow the redirect to the external app, and then get the auth cookie from the header there. This is an integration test, so we want real servers, not mocks. In order to set this up, our test starts two embedded servers using our startTestWebApp function, which configures each server with a URL path, headers, and optional response body. We'll get into details of startTestWebapp further down, but this is all that's needed for our test to then make requests against the servers like our scanner does and verify the responses.
Note that the @AfterEach includes a stopTestWebApps() function that cleans up the embedded servers between each test (details further down).
Also note that in general, all our test helpers like startTestWebapp and stopTestWebapps() are defined in a TestUtils.kt file that's a shared test utility.
@Test
fun testExternalCookieAuth() {
// given
val authAppPort = startTestWebApp {
routing {
getBodyFixture("/login", "/auth/body_good.html", "/auth/header_200_cookie.txt")
}
}
val appPort = startTestWebApp {
routing {
getHeaderFixture("/login", "/auth/header_302.txt", mapOf("REDIR_PORT" to authAppPort.toString()))
}
}
// when/then/etc
}
@AfterEach
fun teardown() {
stopTestWebApps()
}
startTestWebApp - Start An Embedded Server
startTestWebApp starts an embedded server on a given host and port, and passes along an Ktor Application block to the server. Keep in mind that in our calling tests, this has a Application.routing block where we set up the URLs with their configuration for what we want returned.
It also adds the newly created server to a list so we can keep track of them.
val testWebApps: MutableMap<Int, ApplicationEngine> = mutableMapOf()
fun startTestWebApp(port: Int = findTcpPort(32768, 42768), host: String = "localhost", block: Application.() -> Unit): Int {
val server = embeddedServer(Netty, port, host) {
block()
}
testWebApps[port] = server
server.start()
return port
}
stopTestWebApps - Stop All The Embedded Servers
stopTestWebApps loops over our list of servers and shuts them down. In our test cases, this is called in the @AfterEach function.
fun stopTestWebApps() {
testWebApps.entries.forEach {
it.value.stop(1000, 1000)
}
testWebApps.clear()
}
getBodyFixture - GET With Response Body
getBodyFixture allows us to set up a GET request against a given URL path, the body returned, HTTP status code, custom headers, and the content type of the response. The body and headers (which also has the status code and content type) are defined in flat text files that are put in the project's test resources directory. In our test, we specify the relative path to the flat files and this will return them as part of the request.
fun Routing.getBodyFixture(
path: String,
bodyResourcePath: String,
headerResourcePath: String? = null,
contentType: ContentType = ContentType.Text.Html,
overrides: Map<String, String> = emptyMap(),
block: Routing.() -> Unit = {}
) {
get(path) {
val bodyText = withContext(Dispatchers.IO) {
IOUtils.toString(
TestUtils::class.java.getResourceAsStream(bodyResourcePath),
Charset.defaultCharset()
)
}
if (headerResourcePath != null) {
val (status, headers) = getHeaderResource(headerResourcePath, overrides)
call.addHeaders(headers)
call.respondText(bodyText, status = HttpStatusCode.fromValue(status))
} else {
call.respondText(bodyText, contentType = contentType)
}
}
block(this)
}
getHeaderFixture - A 302 Redirect GET Without A Body
getHeaderFixture lets us set up a URL path that returns only headers, which in the case of a 302 Redirect, is all that's needed. The headers (along with the status code and content type) are defined in a flat text file in our test resource path. The test just specifies that path and this will return its content as headers.
fun Routing.getHeaderFixture(
path: String,
headerResourcePath: String,
overrides: Map<String, String> = emptyMap(),
block: Routing.() -> Unit = {}
) {
get("/") {
call.respondText("test app")
}
get(path) {
val (status, headers) = getHeaderResource(headerResourcePath, overrides)
call.addHeaders(headers)
call.respondHeadersFixture(headers, status)
}
block(this)
}
Header Resource File
The header resource files have a simple format. The first line is the returned HTTP status, and the rest are set as HTTP headers.
Runtime overrides can be denoted as tokens with @ on either side (like @REDIR_PORT@ below):
HTTP/1.1 302 Found
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Location: http://localhost:@REDIR_PORT@/users/2
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache
X-Request-Id: 4f044df9-64a2-4f0c-a23c-84556d8fde57
X-Runtime: 0.069833
Transfer-Encoding: chunked
getHeaderResource - Read In Header Resource File (with optional token override values)
getHeaderResource reads in the header resource file and parses it for use in the response. For runtime values (like an embedded server's port), a map of token overrides can be provided to replace value holders in the file (like @REDIR_PORT@ above).
suspend fun getHeaderResource(
headerResourcePath: String,
overrides: Map<String, String> = mapOf()
): Pair<Int, List<Pair<String, String>>> {
return withContext(Dispatchers.IO) {
val rawHeaderLines = IOUtils.toString(
TestUtils::class.java.getResourceAsStream(headerResourcePath),
Charset.defaultCharset()
).lines().map { it.trim() }.filter { it.isNotEmpty() }
val status = rawHeaderLines.first().split(" ")[1].toInt()
status to rawHeaderLines.takeLast(rawHeaderLines.size - 1)
.map {
val parts = it.split(":", limit = 2)
parts[0] to override(parts[1], overrides)
}
}
}
respondHeadersFixture - Sets Content Type and HTTP Status
private val engineOnlyHeaders = setOf("content-length", "content-type", "transfer-encoding")
suspend fun ApplicationCall.respondHeadersFixture(headers: List<Pair<String, String>>, status: Int) {
val ct = headers.find { it.first.toLowerCase() == "content-type" }?.second
respondText(ct?.let { it1 -> ContentType.parse(it1) }, HttpStatusCode.fromValue(status)) {
""
}
}
addHeaders - Sets Headers On The Response
fun ApplicationCall.addHeaders(headers: List<Pair<String, String>>) {
headers.forEach {
if (!engineOnlyHeaders.contains(it.first.toLowerCase())) {
response.header(it.first, it.second)
}
}
}
Verification
With all that in place, our test with a few lines of code, can then do the following sequence:
Issue a GET /login HTTP/1.1 against the appPort
Read the 302 Redirect response
Issue a GET /login HTTP/1.1 against the appAuthPort
Read the 200 OK response and verify the value in the Set-cookie header
Summary
For testing external cookie authentication, having control over HTTP responses and headers is needed. The test client needs to make a GET that returns a 302 to a dynamic port, then make another GET against the dynamic port, and finally get the cookie. Ktor provides a deep toolbox of hooks for setting things up, and we have written some helper wrapper functions to make our tests easy to write, read, and maintain.