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

Structured parsing of the Origin header (issue #2007) #2082

Merged
merged 2 commits into from
Sep 13, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion core/src/main/scala/org/http4s/headers/Origin.scala
Original file line number Diff line number Diff line change
@@ -1,4 +1,49 @@
package org.http4s
package headers

object Origin extends HeaderKey.Default
import cats.data.NonEmptyList
import org.http4s.parser.HttpHeaderParser
import org.http4s.util.{Renderable, Writer}

sealed abstract class Origin extends Header.Parsed {
def key: Origin.type =
Origin
}

object Origin extends HeaderKey.Internal[Origin] with HeaderKey.Singleton {
// An Origin header may be the string "null", representing an "opaque origin":
// https://stackoverflow.com/questions/42239643/when-does-firefox-set-the-origin-header-to-null-in-post-requests
case object Null extends Origin {
def renderValue(writer: Writer): writer.type =
writer << "null"
}

// If the Origin is not "null", it is a non-empty list of Hosts:
// http://tools.ietf.org/html/rfc6454#section-7
case class HostList(hosts: NonEmptyList[Host]) extends Origin {
def renderValue(writer: Writer): writer.type = {
writer << hosts.head
hosts.tail.foreach { host =>
writer << " "
writer << host
}
writer
}
}

// A host in an Origin header isn't a full URI.
// It only contains a scheme, a host, and an optional port.
// Hence we re-used parts of the Uri class here, but we don't use a whole Uri:
// http://tools.ietf.org/html/rfc6454#section-7
final case class Host(scheme: Uri.Scheme, host: Uri.Host, port: Option[Int] = None)
extends Renderable {
def toUri: Uri =
Uri(scheme = Some(scheme), authority = Some(Uri.Authority(host = host, port = port)))

def render(writer: Writer): writer.type =
toUri.render(writer)
}

override def parse(s: String): ParseResult[Origin] =
HttpHeaderParser.ORIGIN(s)
}
6 changes: 4 additions & 2 deletions core/src/main/scala/org/http4s/parser/HttpHeaderParser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ object HttpHeaderParser
with StrictTransportSecurityHeader
with ProxyAuthenticateHeader
with WwwAuthenticateHeader
with ZipkinHeader {
with ZipkinHeader
with OriginHeader {

type HeaderParser = String => ParseResult[Parsed]

Expand Down Expand Up @@ -103,7 +104,8 @@ object HttpHeaderParser
Header("Cookie", "http4s=cool"),
Header("Host", "http4s.org"),
Header("X-Forwarded-For", "1.2.3.4"),
Header("Fancy-Custom-Header", "yeah")
Header("Fancy-Custom-Header", "yeah"),
Header("Origin", "http://example.com:12345")
).map(parseHeader)
assert(results.forall(_.isRight))
}
Expand Down
53 changes: 53 additions & 0 deletions core/src/main/scala/org/http4s/parser/OriginHeader.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.http4s
package parser

import cats.data.NonEmptyList
import java.nio.charset.{Charset, StandardCharsets}
import org.http4s._
import org.http4s.headers.Origin
import org.http4s.internal.parboiled2._

trait OriginHeader {

def ORIGIN(value: String): ParseResult[Origin] =
new OriginParser(value).parse

private class OriginParser(value: String)
extends Http4sHeaderParser[Origin](value)
with Rfc3986Parser {

override def charset: Charset =
StandardCharsets.ISO_8859_1

def entry: Rule1[Origin] = rule {
nullEntry | hostListEntry
}

// The spec states that an Origin may be the string "null":
// http://tools.ietf.org/html/rfc6454#section-7
//
// However, this MDN article states that it may be the empty string:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
//
// Although the MDN article is possibly wrong,
// it seems likely we could get either case,
// so we read both as Origin.Null and re-serialize it as "null":
def nullEntry: Rule1[Origin] = rule {
(str("") ~ EOI | str("null") ~ EOI) ~> { () =>
Origin.Null
}
}

def hostListEntry: Rule1[Origin] = rule {
(host ~ zeroOrMore(" " ~ host)) ~> { (head: Origin.Host, tail: Seq[Origin.Host]) =>
Origin.HostList(NonEmptyList(head, tail.toList))
}
}

def host: Rule1[Origin.Host] = rule {
(scheme ~ "://" ~ Host ~ Port) ~> { (s, h, p) =>
Origin.Host(s, h, p)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class CORSSpec extends Http4sSpec {
anyOrigin = false,
allowCredentials = false,
maxAge = 0,
allowedOrigins = Set("http://allowed.com/"),
allowedOrigins = Set("http://allowed.com"),
allowedHeaders = Some(Set("User-Agent", "Keep-Alive", "Content-Type")),
exposedHeaders = Some(Set("x-header"))
)
Expand All @@ -33,7 +33,7 @@ class CORSSpec extends Http4sSpec {

def buildRequest(path: String, method: Method = GET) =
Request[IO](uri = Uri(path = path), method = method).replaceAllHeaders(
Header("Origin", "http://allowed.com/"),
Header("Origin", "http://allowed.com"),
Header("Access-Control-Request-Method", "GET"))

"CORS" should {
Expand Down
73 changes: 73 additions & 0 deletions tests/src/test/scala/org/http4s/parser/OriginHeaderSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package org.http4s
package parser

import cats.data.NonEmptyList
import org.http4s.headers.Origin
import org.specs2.mutable.Specification

class OriginHeaderSpec extends Specification with Http4sSpec {
val host1 = Origin.Host(Uri.Scheme.http, Uri.RegName("www.foo.com"), Some(12345))
val host2 = Origin.Host(Uri.Scheme.https, Uri.IPv4("127.0.0.1"), None)

val hostString1 = "http://www.foo.com:12345"
val hostString2 = "https://127.0.0.1"

"Origin value method".can {
"Render a host with a port number" in {
val origin = Origin.HostList(NonEmptyList.of(host1))
origin.value must be_==(hostString1)
}

"Render a host without a port number" in {
val origin = Origin.HostList(NonEmptyList.of(host2))
origin.value must be_==(hostString2)
}

"Render a list of multiple hosts" in {
val origin = Origin.HostList(NonEmptyList.of(host1, host2))
origin.value must be_==(s"$hostString1 $hostString2")
}

"Render an empty origin" in {
val origin = Origin.Null
origin.value must be_==("null")
}
}

"OriginHeader parser".can {
"Parse a host with a port number" in {
val text = hostString1
val origin = Origin.HostList(NonEmptyList.of(host1))
val headers = Headers(Header("Origin", text))
headers.get(Origin) must beSome(origin)
}

"Parse a host without a port number" in {
val text = hostString2
val origin = Origin.HostList(NonEmptyList.of(host2))
val headers = Headers(Header("Origin", text))
headers.get(Origin) must beSome(origin)
}

"Parse a list of multiple hosts" in {
val text = s"$hostString1 $hostString2"
val origin = Origin.HostList(NonEmptyList.of(host1, host2))
val headers = Headers(Header("Origin", text))
headers.get(Origin) must beSome(origin)
}

"Parse an empty origin" in {
val text = ""
val origin = Origin.Null
val headers = Headers(Header("Origin", text))
headers.get(Origin) must beSome(origin)
}

"Parse a 'null' origin" in {
val text = "null"
val origin = Origin.Null
val headers = Headers(Header("Origin", text))
headers.get(Origin) must beSome(origin)
}
}
}