-
Notifications
You must be signed in to change notification settings - Fork 275
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
feat: CSRF plugin #1006
feat: CSRF plugin #1006
Conversation
Co-authored-by: Simon Sapin <[email protected]>
✅ Deploy Preview for apollo-router-docs canceled.
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't expect you to implement this within hours of my incoherent "omg I just found an issue but also have to pick up my daughter at school in 7 minutes" ramblings — wow, thanks!
|
||
fn content_type_requires_preflight(headers: &HeaderMap) -> bool { | ||
headers | ||
.get(header::CONTENT_TYPE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One thing we need to be very careful with when doing this parsing is that we are parsing the correct value in the first place. HTTP allows you to specify a header more than once. For the purposes of the fetch spec, the string that the browser extracts and passes to the "parse a mime type" algorithm is "all values of the header, joined with comma-space".
Also note that mime types can have "params", like text/plain; charset=utf8
. This is close enough to text/plain
for the sake of not preflighting!
So let's say you tried to send a request with
content-type: text/plain; foo=
content-type: application/json
The browser would combine this to
content-type: text/plain; foo=, application/json
which is MIME type with essence text/plain
and a parameter foo
with value , application/json
. Which does not require preflight!
If the browser actually sent this as two separate headers and then for some reason our server applied "just pay attention to the last one" then we'd think this was preflighted when it really wasn't.
In practice this works out OK because the browser will actually send this as a single joined header anyway... but it's still reasonable to try to be careful anyway. http::HeaderMap says it is a multi-map! Looks like get
returns the first value associated. I'd suggest using get_all
and joining on ,
(comma space) to be as close as possible to the spec. (This is invisible in the Apollo Server 3 code because the CSRF prevention code receives a Headers
object that works like the browser headers object and does the comma-space join automatically.) Worth testing too!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://fetch.spec.whatwg.org/#cors-safelisted-request-header claims
This intentionally does not use [extract a MIME type](https://fetch.spec.whatwg.org/#concept-header-extract-mime-type) as that algorithm is rather forgiving and servers are not expected to implement it.
The example seems to concur (and explicitly show what would happen if a client sent text/plain and application/json), so I'm tempted to:
- use
get_all
- parse and get the essence of each value
- check if any essence isnt part of the non preflight
it also claims If mimeType is failure, then return false.
but i wouldnt mind us being more strict in that regard tbh.
Edit: it does use the parse a MIME type algorithm, but not the /extract/ a MIME type algorithm
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be fixed in 331a5e1 but i ll add some tests to make sure it s the case
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@o0Ignition0o I'll look at the code in a sec, but I don't think "parse and get the essence of each value" is what I'm asking for. The fetch spec wants you to parse just one content-type — it's just that that content-type should be formed by combining all content-type headers rather than by just arbitrarily picking one of them (which is what get()
does)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you please point me to the spec? I can't seem to find the "combine all content-type headers" part (and all i can find is how to parse one mime type https://mimesniff.spec.whatwg.org/#parse-a-mime-type)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I found it!
.get(header::CONTENT_TYPE) | ||
.map(|content_type| { | ||
if let Ok(as_str) = content_type.to_str() { | ||
if let Ok(mime_type) = as_str.parse::<mime::Mime>() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To match the fetch spec, this needs to use precisely this "parse a MIME type" algorithm. Looks like this is the implementation in the mime
package, which based on the filename is RFC7231.
Looking at the code, I see some things that look different. For example, this mime type parser considers leading whitespace to be an error instead of just stripping it. (So if you change line 235 to " text/plain"
it thinks it's the error case which would have been preflighted.) In practice this is probably fine: the fetch algorithm is designed to be run on the client (ie with whatever garbage headers are in the JS) but when you're parsing a header that you're reading on the server presumably you drop all the leading whitespace.
But worse is that it seems to only honor spaces, not tabs. So it chokes on text/plain;\tfoo=bar
which seems wrong. Looking at issues somebody found this and in general people are asking if it's maintained. There's a relatively recent issue about a replacement. That one is brand new and doesn't have much usage yet but at least gets tabs correct.
Since I can't find a MIME parser that claims to exactly implement the fetch/whatwg parser like I could in npm, maybe the logic of "if a content-type is provided but we can't parse it, assume it's safe" isn't a good idea here, and we should go with "we only accept requests that have a content-type that we successfully parsed and they didn't contain one of the three non-preflighted types". (ie, I trust that the npm whatwg-mimetype
has accurate error handling but don't trust that the Rust implementations might incorrectly reject a string that should parse.) If you change the handling of the error case to rejecting (unless one of the other headers is provided of course) then it's probably fine to use the popular-if-undermaintained crate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm going to keep a close eye on the new crate, which seems to be going in the right direction.
I agree, let's:
- treat failure to turn the HeaderValue into a valid utf8 string as a condition that would have triggered preflight
- trim and replace \t with ' ' before we try to parse
- treat failure to parse mime type as a non sufficient condition to trigger a preflight
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be fixed in 331a5e1 but i ll add some tests to make sure it s the case
todo: add more documentation + actually test the yml + update the cors docs tomorrow with simon
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know enough about how CSRF prevention should be implemented to approve, but I've asked a couple of questions...
…it comes to content type parsing
CONTRIBUTING.md
Outdated
|
||
To build and run the documentation site locally, you'll have to install the relevant packages by doing the following | ||
from the root of the `router` repository: | ||
|
||
```sh | ||
cd docs | ||
npm i | ||
npm start | ||
npm start --> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the three steps defined above:
cd docs
npm i
npm start``` are out of date; We will turn this //TODO comment into an issue, which @SimonSapin wants to tackle as a followup
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’ve submitted #1036. Maybe undo this chunk here to avoid a merge conflict?
docs/source/configuration/cors.mdx
Outdated
@@ -1,10 +1,210 @@ | |||
--- |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's separate out documentation to another PR?
Two breaking sections: - one that explains the CORS default behavior changed - one that explains the CSRF plugin will reject simple requests by default. One feature section that explains a new CSRF plugin now exists.
…ests+ add an failed csrf check integration test
This Pull request does a couple of things:
allow_headers
behavior from allow onlyContent-Type
,apollographql-client-name
andapollographql-client-version
to mirror the receivedaccess-control-request-headers
Co-authored-by: Simon Sapin [email protected]