Golang's network stack attempts to parse HTTP headers as UTF-8
twitter.com
twitter.com
But there's a bunch of hard-to-quantify side-risks based on corner-cases that could lead to stuff like request header injection, server-side parameter pollution, and host-header attacks. I'm excited to see what shows up.
It's great that they're fixing this anyway, but that's a bad policy.
A common example "órny", part of a number of Polish place and personal names. ISO 8859-1 (and -2, and others) form would be F3 72 6E 79. UTF-8 treats F3 as a start of a 4 byte sequence where each subsequent character is in 80 - BF range (as specified in RFC 3629):
UTF8-4 = %xF0 %x90-BF 2( UTF8-tail ) / %xF1-F3 3( UTF8-tail ) /
%xF4 %x80-8F 2( UTF8-tail )
UTF8-tail = %x80-BF
This nicely causes an exception in both Java and Python, for instance, if they were to do what Go does here - and there is nothing anyone from adding an X- header to the request or response to try just that.Edit: And with HTTP having case-insensitive matching (which is most likely broken in lots of hand written implementations). This is rife with the possibility for errors
Taken from RFC 7230 3.2.4 Field parsing
Historically, HTTP has allowed field content with text in the
ISO-8859-1 charset [ISO-8859-1], supporting other charsets only
through use of [RFC2047] encoding. In practice, most HTTP header
field values use only a subset of the US-ASCII charset [USASCII].
Newly defined header fields SHOULD limit their field values to
US-ASCII octets. A recipient SHOULD treat other octets in field
content (obs-text) as opaque data.This is not really a 'should', IMHO, because fields are defined as OCTETS, iirc. Based on that, a compliant and robust implementation must treat them as opaque data.
> Non-US-ASCII content in header fields and the reason phrase has been obsoleted and made opaque (the TEXT rule was removed).
RFC 2616:
field-value = ( field-content | LWS )
field-content = <the OCTETs making up the field-valu and consisting of either TEXT or combinations of token, separators, and quoted-string>
Hence to me fields must be treated as opaque data for backward compatibility and robustness. If anything, existing applications that are compliant with RFC 2616 already do that, right? ;)
TEXT = <any OCTET except CTLs,
but including LWS>
IETF rewrote the productions not to use TEXT, but stopped short from banning the old behaviour.So, for instance, where 2616 states: Reason-Phrase = <TEXT, excluding CR, LF> And 7230 has: reason-phrase = ( HTAB / SP / VCHAR / obs-text )
It is making sure that any application that conforms to 2616 still conforms to 7230 by not making it illegal (MUST) to parse obs-text... Just something you SHOULD not not do. They are simply making it so any new header added is defined as SP / VCHAR only (quoted, possibly).
That's why they write that it should be treated as opaque data. My point (and the point of the comment I was replying to) is that 'should' is perhaps too weak a word in the context because previous history. In any case for robustness it is a must to treat it that way.
Transfer-Encoding = 1#transfer-coding
transfer-coding = "chunked" ; Section 4.1
/ "compress" ; Section 4.2.1
/ "deflate" ; Section 4.2.2
/ "gzip" ; Section 4.2.3
/ transfer-extension
transfer-extension = token *( OWS ";" OWS transfer-parameter )
token = 1*tchar
tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." /
"^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
… which does not permit non-ASCII. As a generic header, maybe (but the decoding should be into ISO-8859-1, as the RFC notes…), but at the point at which you parse it into a Transfer-Encoding header, it is no longer valid.I don't remember if the HTTP spec has it, but I clearly remember the HTML spec has some parts that explicitly say "case-insensitive in the ASCII range only" (i.e. 61-7a = 41-5a), presumably to prevent things like this from happening.
For example, in English "i" and "I" are case-insensitively equal but in Turkish they are not: "i" is case-insensitively equal to "İ" (U+0130) and "I" is case-insensitively equal to "ı" (U+0131).
So if you wanted to do full-unicode case-insensitive things you would need to decide what happens with <DİV> or <dıv> and whether it depends on the language of the page or what. Doing ASCII-only case-insensitivity sidesteps all that, while admittedly privileging English over other languages.
I translated an x-face encoder from js to go for one of my introductory projects and initially couldn't get the correct output from `b64.StdEncoding.EncodeToString` due to the fact that x-face strings are single-byte ASCII ISO-8859-1 encoded[1].
[0]: https://blog.golang.org/strings
[1]: https://stackoverflow.com/questions/43461679/escaping-of-hex...
Also, as a nit, I don't think iso-8859-1 is ASCII.
I'm not a go expert, but my understanding is that go "strings" are really all just a series of bytes and it's the standard library functions which treat string operations as UTF-8 encoded by default. I think this reddit post[0] explains it nicely. I suppose the simplicity of "just work with the bytes and apply encoding when necessary" follows go's simplicity and composability/interface patterns well.
> Also, as a nit, I don't think iso-8859-1 is ASCII.
We're splitting hairs here, but ISO-8859-1 is the eight-bit extension to 7-bit ASCII, no? It includes the standard 128 ASCII characters + up to an additional 128 characters (191 in total for ISO-8859-1 Latin-1 Western European, iirc).
[0]: https://old.reddit.com/r/golang/comments/a5v3fn/are_strings_...
So, I thereby was neither surprised to read that the Go HTTP server did this obviously-wrong thing... NOR was I particularly surprised to read the person complaining about it also didn't seem to understand the correct behavior, either (though I do appreciate this is a bit "unfair"--I mean, it's a tweet ;P--but I think still makes for an interesting point here) :/. I have dealt with so many broken HTTP clients and servers over the years written by people who probably also never read the manual that came with their microwave because "documentation is for idiots" or whatever, that I have just become demoralized about it :(.
A few months ago, I was complaining about this, with a short list of stuff everyone tended to get wrong, and this HTTP header field encoding issue was on my shortlist :(.
https://news.ycombinator.com/item?id=24835617
> FWIW, I have encountered tons of issues in peoples' "this is easy, right?" implementations of 100 Continue (including in some at-the-time major Ruby HTTP servers), non-ASCII encoding in HTTP headers (which has a ton of subtle rules, and just throwing around UTF-8 is not correct), and chunked Transfer-Encoding (though I can't remember what was wrong); further, people often don't even realize HTTP trailers exist at all, which is a bit infuriating :/. (Note, though, that I am still 100% on the side of the anti-HTTP/2 camp in this thread.) The core problem is that somehow people think they can implement a spec without even, you know, reading the spec, which is so far past hubris as to have gone through incompetence into obvious negligence.
AFAIK, HTTP header fields are (in their generality) specified to be read as binary octets... but then are restricted to using ISO-8859-1 with the premise you are supposed to run them through a MIME parser? Like, some fields have strict formats, so you can't do the full decoding until you know what you are working with--and so I would argue an HTTP library should give you the raw binary data (though decoding them through ISO-8859-1 first isn't supposed to be "wrong")--but for fields with user-displayable text (including HTTP status messages!) you can seriously embed "Q" encoded strings (of all things) if you want, a la RFC 2047. You can't assume a field is restricted to ASCII too early in the parsing process.
To tell another story of the importance of reading the--in this case, not even long!--specification: on Windows, filenames are stored as UCS-2 (as it all predates UTF-16), and is most likely most correctly modeled as an arbitrary sequence of non-0 16-bit values; meanwhile, on Unix systems, a filename is pretty much just an arbitrary sequence of non-0 8-bit values (with some weirdness around the slash character... the API surface doesn't support it really but sometimes the filesystem does). The premise that filenames have a display encoding mostly comes from the software rendering the strings and is then defined in terms of a bunch of hacks involving "locales", and isn't a property of the underlying API or storage semantics.
You can't assume in either case that they are canonical Unicode representations, and so storing them as "strings" in your program is super wrong and can lead to anything from data storage mistakes to security vulnerabilities. The Python 3 API filesystem API was thereby so bad that it would do things like just skip over files during directory traversal that didn't map to its attempt to decode stuff as Unicode. Like: if you wanted to hide a file from a Python 3 program, you could just make it invalid Unicode :/.
(I realized people reading this comment might find a reference on this fun, and so found this seemingly-comprehensive postmortem. I haven't read it, but it at least looks really good ;P.)
https://vstinner.github.io/python30-listdir-undecodable-file...
I thereby find the whole Python 3 debacle extra demoralizing as it felt like a bunch of people who didn't truly understand how hard the problem they were dealing with was--and how much of the world actually is binary, and how often (when dealing with things like filesystems and network protocols) parsing binary sequences as pseudo-strings is "correct"--both sometimes-correctly insisting everyone was wrong while themselves failing to build something even minimally useful until like, Python 3.4 or so; and, in the end, I feel like they just ended up creating a "new mess" by having filenames now encoded as "Unicode strings"... but with weird exceptions for allowing round-trip behaviors of strings that couldn't be decoded :(.
The reality is that there are a number of things that developers work with which are pseudo-strings, and languages should provide good tooling for working with them. We see the same issue come up with HTTP headers and filenames: things that are actually binary but have string-like structure. There is a reason why both C++ and Python 2 are often looked at fondly by low-level developers for providing low-level string representations, even while they end up encouraging broken behavior higher up the stack. I haven't looked enough at Rust's standard library to see if they truly nailed this yet, but maybe one day we will have a language that encourages the right behavior for these mechanisms that are neither strictly string not strictly binary.
How does Python 3 handle these HTTP headers and other things that are purely ASCII? Does it treat them as type `bytes` instead of (Unicode) `str`?
https://docs.python.org/3/library/email.header.html#email.he...
This actually looks like a really solid implementation--like, I am super impressed by this (though watch it suck... everything sucks ;P)--as it doesn't assume any ability to flatten the result! That said, I am also deeply concerned that this might pre-decode something that should actually be in ASCII all the way down from MIME (which, notably, is complex enough of a format to include support for embedded comments). I kind of want to do some auditing here, to make sure there aren't some parser discrepancy vulnerabilities in common Python HTTP frameworks ;P.
But like, I think this object represents a sequence of strings each with its own encoding, allowing it to "Q" encode the result exactly as specified (and correctly round trip something it parsed, which still does feel a tad dangerous, and maybe the API lets you avoid it in those cases). It then has an __str__ implementation (to let you use it as a string) that explicitly claims to return an "approximation" of the header (which may or may not be a safe thing to use in different circumstances; but like, that in some sense isn't the API's fault?...).
The most common form of string you'll see is `String`[0], or its view type `str`[1], which are UTF-8 encoded. These have most of the string functions you'd expect to have.
Of course, as you mentioned, operating systems don't all use UTF-8, which brings us to `OsString`[2], and its view type `OsStr`[3]. This is the data type that string data from the OS is represented as. Both are basically bundles of bytes, with no specific encoding. You'll note that these are in the FFI namespace.
You then have `PathBuf`[4] and its view type `Path`[5], which are wrappers around `OsString` and `OsStr` respectively, and provide path-specific functionality.
All the file system related APIs take anything that implements `AsRef<Path>`, which is runtime-cost-free for all of these types. There's not many functions that take an `OsStr`, but those that do take it in the form `AsRef<OsStr>`, which like `Path` is runtime-cost-free for all these types. None of the APIs take `PathBuf` or `OsString`.
Return values are typically `PathBuf` for file system APIs, and `OsString` for other APIs. The only exceptions I know of are the functions for getting environment variables and command line arguments, which also provide functions that return `String` because a common use case would be converting them anyway.
[0] https://doc.rust-lang.org/std/string/struct.String.html
[1] https://doc.rust-lang.org/std/primitive.str.html
[2] https://doc.rust-lang.org/std/ffi/struct.OsString.html
[3] https://doc.rust-lang.org/std/ffi/struct.OsStr.html
We cannot tolerate half-written buggy clients for an eternity.
Let us have more errors. Computers aren't humans. You have to specify things precisely, not throw a blob at them and let them figure it out. They can't. The attempts at that have only led to countless bugs and costly security breaches.
As an example: https://tools.ietf.org/html/rfc6126. The document starts with "Updated by: 7298, 7557".
Regarding HTTP, if you pass by the wikipedia article, you have the list of RFC on the menu on the right: https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol.
RFC 1945 HTTP/1.0 (1996)
RFC 2616 HTTP/1.1 (1999)
RFC 7540 HTTP/2 (2015)
RFC 7541 Header Compression (2, 2015)
RFC 7230 Message Syntax and Routing (1.1, 2014)
RFC 7231 Semantics and Content (1.1, 2014)
RFC 7232 Conditional Requests (1.1, 2014)
RFC 7233 Range Requests (1.1, 2014)
RFC 7234 Caching (1.1, 2014)
RFC 7235 Authentication (1.1, 2014)
- RFC are inconsistent
- out of touch with the real world
- contain security issues
- not clear
- contradict themselves
So you can look at three majors HTTP server and sometimes they will implement / allow things differently becauset they have no other choice.
In my case I wanted to interact with a few servers and not write a HTTP client, so I used an existing HTTP client library. Turns out, the library did not handle those servers, so I had to write the header parsing myself, at least the cookie parsing.
I wonder if there are RFC 2047 cookies and you need to parse them, or just send them back as they came
This tells a lot about HTTP as well.
Finally just wrote code to loop through all possible encodings and ways to change encodings. Wait until a match against a known string was found.
Proper encodings found in a few minutes.
I mean you need to have a client-id and their permissions for any outside request coming to server.
I’m using Golang in my webserver but it wouldn’t hand-out secret pages as requesting client wouldn’t have authority to do so.
I feel like Golang's UTF8 handling is the least of the worries for these kinds of attacks, it points out that entire backend system was constructed wrongly, number one rule of any backend systems: You cannot trust the client.
Supporting round-trip conversion may sound pointless now that Unicode is ubiquitous, but it was probably necessary to make gradual adoption practical.
Ref: https://en.m.wikipedia.org/wiki/Unicode_compatibility_charac...
Nevermind the fact that some parts of HTTP are really just binary written as text, and now even most non-developers know what a 404 is...
Now, what are the security implications of this?
(beside the chunk thingy)
That's a terrible reason. HTTP header values are not necessarily UTF-8, and an implementation that blindly assumes they are is broken.
> Now, what are the security implications of this? (beside the chunk thingy)
Many variations of the "chunk thingy" where values considered safe in one context may be unsafe in another context.
Like so many parts of Golang, this is a good example of how "good enough" engineering often really isn't good enough. HTTP is pretty thoroughly specified, and the HTTP stack included in a language should be thoroughly tested against that spec. If you can't achieve that, just leave it out of the stdlib!
Actually, they are explicitly specified as ASCII ("follow the same generic format as that given in Section 3.1 of RFC 822"): https://tools.ietf.org/html/rfc822#section-3.1
They are not, only new headers added after RFC 7320 are ASCII, pre-existing ones may contain high-bit characters which implementations compliant with 7230 version of HTTP/1.1 SHOULD not parse. They stopped short from making it a MUST so that applications compliant with RFC 2616 (where they were to be treated as ISO-8859-1!) was allowed. Or, more simply, they stopped themselves from making RFC 7230 describe new, incompatible HTTP/1.2.
Consider this: all strings are utf-8, so working with pure ASCII is much more work in golang.
It leads to all kind of issues, not only security problems.
The whole "it would be more work" as a justification for not doing things properly is one of the roots of the more general issues with Go I alluded to in my post above.
3) Go specification requires UTF-8 in only one place: the encoding of Go source files string literals.
2) Pure ASCII is a subset of UTF-8, ie. pure ASCII _is_ valid UTF-8. There is _no_ more work and there are _no_ security problems when working with pure ASCII as UTF-8, because that's what it is.
That said, I tweeted this rather than posting a whitepaper because I haven't thoroughly explored the impact. I've shared my guess about what other issues you'll find here: https://news.ycombinator.com/item?id=25858015
And I posted a full whitepaper/presentation on the technique here: https://portswigger.net/research/http-desync-attacks-request...
[0] https://daringfireball.net/linked/2020/05/02/psychic-paper
You would think an old http header is a string. It's not. It looks like a string though :)
Actually its not guaranteed to be UTF-8, only a sequence of bytes with some convenience methods: https://golang.org/ref/spec#String_types
However, the existence of the 'strings' package and the 'range' operator give the impression that all strings are UTF-8.
> In a go a string is an immutable byte slice assumed to be valid utf8, with associated methods and users which all assume that slice of memory is valid utf8.
Which is in my opinion the worst choice you could make when designing a type. If I'm working in a typed language because of so-called "type safety", if I can't trust my types to actually enforce their guarantees, what can I trust ?
This is not a defence of Go but more of a warning to Go programmers.
This is a very, very sloppy way to handle text, and will inevitably lead to a huge number of mistakes and broken software.
Any well-designed modern language really should make a strong separation between raw byte buffers and text strings. They are very different things, and one can definitely not trivially be converted to the other.