5

I am using Vertx 3 with Kotlin, and at times I need to return a specific URI from the perspective of the public URL which is not the same as what the Vertx-web request thinks my URL is. This is likely due to my load balancer or proxy receiving one URL, and then forwarding to my application on an internal URL.

So if I do this:

val publicUrl = context.request().absoluteURI() 

I end up with a URL like http://10.10.103.22:8080/some/page instead of https://app.mydomain.com/some/page. Everything is wrong about that URL!

I found a header that supposedly tell me more about the original request such as X-Forwarded-Host but it only includes app.mydomain.com or sometimes it has the port app.mydomain:80 but that isn't enough to figure out all parts of the URL, I end up with something like http://app.mydomain.com:8080/some/page which is still not the correct public URL.

I also need to handle not just my current URL, but peer URL's, like while on page "something/page1" go to "something/page2" on same server. The same problems mentioned about when I try to resolve to another URL because important parts of the public URL are unobtainable.

Is there a method in Vertx-web I'm missing to determine this public URL, or some idiomatic way to solve this?

I'm coding in Kotlin, so any examples for that language are great!

Note: this question is intentionally written and answered by the author (Self-Answered Questions), so that solutions for interesting problems are shared in SO.

Community
  • 1
  • 1
Jayson Minard
  • 84,842
  • 38
  • 184
  • 227

1 Answers1

7

This is a more complicated issue, and the logic is the same for most App servers if they do not already provide an URL externalization function.

To do this correctly, you need to handle all of these headers:

  • X-Forwarded-Proto (or X-Forwarded-Scheme: https, and maybe oddballs like X-Forwarded-Ssl: on, Front-End-Https: on)
  • X-Forwarded-Host (as "myhost.com" or "myhost.com:port")
  • X-Forwarded-Port

And if you want to resolve and return a URL that is not the current one you need to also consider:

  • partial without host, for example "/something/here" or "under/me" resolving to the servers public protocol, host, port as well as that abosolute or relative path
  • partial with host/port, for example "//somehost.com:8983/thing" would add the same scheme (http/https) as this server and keep the rest
  • full, URL's that are fully qualified are returned untouched, so they are safe to pass to this function ("http://...", "https://...") and won't be modified

Here is a pair of extension functions to RoutingContext that will handle all these cases and fall back when the load balancer / proxy headers are not present so will work in both cases of direct connections to the server and those going through the intermediary. You pass in the absolute or relative URL (to the current page) and it will return a public version of the same.

// return current URL as public URL
fun RoutingContext.externalizeUrl(): String {
    return externalizeUrl(URI(request().absoluteURI()).pathPlusParmsOfUrl())
}

// resolve a related URL as a public URL
fun RoutingContext.externalizeUrl(resolveUrl: String): String {
    val cleanHeaders = request().headers().filterNot { it.value.isNullOrBlank() }
            .map { it.key to it.value }.toMap()
    return externalizeURI(URI(request().absoluteURI()), resolveUrl, cleanHeaders).toString()
}

Which call an internal function that does the real work (and is more testable since there is no need to mock the RoutingContext):

internal fun externalizeURI(requestUri: URI, resolveUrl: String, headers: Map<String, String>): URI {
    // special case of not touching fully qualified resolve URL's
    if (resolveUrl.startsWith("http://") || resolveUrl.startsWith("https://")) return URI(resolveUrl)

    val forwardedScheme = headers.get("X-Forwarded-Proto")
            ?: headers.get("X-Forwarded-Scheme")
            ?: requestUri.getScheme()

    // special case of //host/something URL's
    if (resolveUrl.startsWith("//")) return URI("$forwardedScheme:$resolveUrl")

    val (forwardedHost, forwardedHostOptionalPort) =
            dividePort(headers.get("X-Forwarded-Host") ?: requestUri.getHost())

    val fallbackPort = requestUri.getPort().let { explicitPort ->
        if (explicitPort <= 0) {
            if ("https" == forwardedScheme) 443 else 80
        } else {
            explicitPort
        }
    }
    val requestPort: Int = headers.get("X-Forwarded-Port")?.toInt()
            ?: forwardedHostOptionalPort
            ?: fallbackPort
    val finalPort = when {
        forwardedScheme == "https" && requestPort == 443 -> ""
        forwardedScheme == "http" && requestPort == 80 -> ""
        else -> ":$requestPort"
    }

    val restOfUrl = requestUri.pathPlusParmsOfUrl()
    return URI("$forwardedScheme://$forwardedHost$finalPort$restOfUrl").resolve(resolveUrl)
}

And a few related helper functions:

internal fun URI.pathPlusParmsOfUrl(): String {
    val path = this.getRawPath().let { if (it.isNullOrBlank()) "" else it.mustStartWith('/') }
    val query = this.getRawQuery().let { if (it.isNullOrBlank()) "" else it.mustStartWith('?') }
    val fragment = this.getRawFragment().let { if (it.isNullOrBlank()) "" else it.mustStartWith('#') }
    return "$path$query$fragment"
}

internal fun dividePort(hostWithOptionalPort: String): Pair<String, Int?> {
    val parts = if (hostWithOptionalPort.startsWith('[')) { // ipv6
        Pair(hostWithOptionalPort.substringBefore(']') + ']', hostWithOptionalPort.substringAfter("]:", ""))
    } else { // ipv4
        Pair(hostWithOptionalPort.substringBefore(':'), hostWithOptionalPort.substringAfter(':', ""))
    }
    return Pair(parts.first, if (parts.second.isNullOrBlank()) null else parts.second.toInt())
}

fun String.mustStartWith(prefix: Char): String {
    return if (this.startsWith(prefix)) { this } else { prefix + this }
}
Jayson Minard
  • 84,842
  • 38
  • 184
  • 227
  • What's with the `request().headers().filter { it.value.isNullOrBlank() }` part? Why only include null or blank request headers in the URI? Is that just for style? – Luke Hutchison Mar 08 '19 at 00:47
  • 1
    Also, it doesn't look like this will quite compile -- `dividePort` returns a `Pair`, so `forwardedHostOptionalPort` is a `String`, which means that `requestPort` can be an integer or a string. Therefore the test in the `finalPort` assignment that compares `requestPort` to two integers will fail. (Unless there's something I don't know about Kotlin...) – Luke Hutchison Mar 08 '19 at 01:20
  • 1
    @LukeHutchison thanks, it would compile but was comparing any `Any?` to an `Int` later when checking the port numbers and `equals()` would work in that direction. But was icky, and is cleaned up now. thanks for catching that! – Jayson Minard Mar 08 '19 at 14:08
  • Jayson -- you mentioned "and maybe oddballs like `X-Forwarded-Ssl: on`, `Front-End-Https: on`, but your code doesn't show how to handle these. What needs to be done for these cases? + Please see my other question in the first comment above. – Luke Hutchison Mar 09 '19 at 21:47
  • @LukeHutchison The headers that are filtered are not useful for the algorithm to figure out the new URL. Headers are not part of the URL, but they are used to determine how to make the new URL. I just stripped out those I didn't want to interfere with anything the code was doing, mostly I didn't want nullable values in the type for my map since none of the Headers I use can be null to be used. – Jayson Minard Mar 11 '19 at 17:10
  • 1
    @LukeHutchison those headers I mentioned as optional would depend on whatever is in front of your server and are not needed unless you have a proxy or load balancer sending something unique. Those were examples of some that people might run into, and the list would depend on what actual deployment model you are using. I didn't list nor incorporate all choices because I do not know all of them. I just gave some examples. – Jayson Minard Mar 11 '19 at 17:12
  • 1
    Surely you wanted `filter {!it.value.isNullOrBlank() }` then? You're missing `! `. – Luke Hutchison Mar 13 '19 at 10:49
  • 1
    @LukeHutchison uff, ... is now `filterNot` – Jayson Minard Mar 15 '19 at 16:45