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

feat: CSRF plugin #1006

Merged
merged 20 commits into from
May 13, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apollo-router-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ tracing = "0.1.34"
tracing-opentelemetry = "0.17.2"
typed-builder = "0.10.0"
urlencoding = "2.1.0"
mime = "0.3.16"

[dev-dependencies]
insta = "1.12.0"
Expand Down
289 changes: 289 additions & 0 deletions apollo-router-core/src/plugins/csrf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
use crate::{register_plugin, Plugin, RouterRequest, RouterResponse, ServiceBuilderExt};
use http::header::{self, HeaderName};
use http::{HeaderMap, StatusCode};
use schemars::JsonSchema;
use serde::Deserialize;
use std::ops::ControlFlow;
use tower::util::BoxService;
use tower::{BoxError, ServiceBuilder, ServiceExt};

#[derive(Deserialize, Debug, Clone, JsonSchema)]
#[serde(deny_unknown_fields)]
struct CSRFConfig {
#[serde(default)]
disabled: bool,
}

static NON_PREFLIGHTED_HEADER_NAMES: &[HeaderName] = &[
header::ACCEPT,
header::ACCEPT_LANGUAGE,
header::CONTENT_LANGUAGE,
header::CONTENT_TYPE,
header::RANGE,
];

static NON_PREFLIGHTED_CONTENT_TYPES: &[&str] = &[
"application/x-www-form-urlencoded",
"multipart/form-data",
"text/plain",
];

#[derive(Debug, Clone)]
struct Csrf {
config: CSRFConfig,
}

#[async_trait::async_trait]
impl Plugin for Csrf {
type Config = CSRFConfig;

async fn new(config: Self::Config) -> Result<Self, BoxError> {
Ok(Csrf { config })
}

fn router_service(
&mut self,
service: BoxService<RouterRequest, RouterResponse, BoxError>,
) -> BoxService<RouterRequest, RouterResponse, BoxError> {
if !self.config.disabled {
ServiceBuilder::new()
.checkpoint(move |req: RouterRequest| {
if should_accept(&req) {
Ok(ControlFlow::Continue(req))
} else {
let error = crate::Error {
message: format!("This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). \
Please either specify a 'content-type' header (with a mime-type that is not one of {}) \
or provide a header such that the request is preflighted: \
https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests",
NON_PREFLIGHTED_CONTENT_TYPES.join(",")),
locations: Default::default(),
path: Default::default(),
extensions: Default::default(),
};
let res = RouterResponse::builder()
.error(error)
.status_code(StatusCode::BAD_REQUEST)
.context(req.context)
.build()?;
Ok(ControlFlow::Break(res))
}
})
.service(service)
.boxed()
} else {
service
}
}
}

fn should_accept(req: &RouterRequest) -> bool {
let headers = req.originating_request.headers();
headers_require_preflight(headers) || content_type_requires_preflight(headers)
}

fn headers_require_preflight(headers: &HeaderMap) -> bool {
headers
.keys()
.any(|header_name| !NON_PREFLIGHTED_HEADER_NAMES.contains(header_name))
o0Ignition0o marked this conversation as resolved.
Show resolved Hide resolved
}

fn content_type_requires_preflight(headers: &HeaderMap) -> bool {
headers
.get(header::CONTENT_TYPE)
Copy link
Member

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!

Copy link
Contributor Author

@o0Ignition0o o0Ignition0o May 11, 2022

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

Copy link
Contributor Author

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

Copy link
Member

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)

Copy link
Contributor Author

@o0Ignition0o o0Ignition0o May 11, 2022

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)

Copy link
Contributor Author

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!

.map(|content_type| {
if let Ok(as_str) = content_type.to_str() {
if let Ok(mime_type) = as_str.parse::<mime::Mime>() {
Copy link
Member

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.

Copy link
Contributor Author

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

Copy link
Contributor Author

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

return !NON_PREFLIGHTED_CONTENT_TYPES.contains(&mime_type.essence_str());
}
}
// If we get here, this means that either turning the content-type header value
// into a string failed (ie it's not valid UTF-8), or we couldn't parse it into
// a valid mime type... which is actually *ok* because that would lead to a preflight.
// (That said, it would also be reasonable to reject such requests with provided
// yet unparsable Content-Type here.)
true
})
.unwrap_or(false)
}

register_plugin!("apollo", "csrf", Csrf);

#[cfg(test)]
mod csrf_tests {

#[tokio::test]
async fn plugin_registered() {
crate::plugins()
.get("apollo.csrf")
.expect("Plugin not found")
.create_instance(&serde_json::json!({ "disabled": true }))
.await
.unwrap();

crate::plugins()
.get("apollo.csrf")
.expect("Plugin not found")
.create_instance(&serde_json::json!({}))
.await
.unwrap();
}

use super::*;
use crate::{plugin::utils::test::MockRouterService, ResponseBody};
use serde_json_bytes::json;
use tower::{Service, ServiceExt};

#[tokio::test]
async fn it_lets_preflighted_request_pass_through() {
let expected_response_data = json!({ "test": 1234 });
let expected_response_data2 = expected_response_data.clone();
let mut mock_service = MockRouterService::new();
mock_service.expect_call().times(2).returning(move |_| {
RouterResponse::fake_builder()
.data(expected_response_data2.clone())
.build()
});

let mock = mock_service.build();

let mut service_stack = Csrf::new(CSRFConfig { disabled: false })
.await
.unwrap()
.router_service(mock.boxed());

let with_preflight_content_type = RouterRequest::fake_builder()
.headers(
[("content-type".into(), "application/json".into())]
.into_iter()
.collect(),
)
.build()
.unwrap();

let res = service_stack
.ready()
.await
.unwrap()
.call(with_preflight_content_type)
.await
.unwrap();

match res.response.into_body() {
ResponseBody::GraphQL(res) => {
assert_eq!(res.data.unwrap(), expected_response_data);
}
other => panic!("expected graphql response, found {:?}", other),
}

let with_preflight_header = RouterRequest::fake_builder()
.headers(
[("x-this-is-a-custom-header".into(), "this-is-a-test".into())]
.into_iter()
.collect(),
)
.build()
.unwrap();

let res = service_stack.oneshot(with_preflight_header).await.unwrap();

match res.response.into_body() {
ResponseBody::GraphQL(res) => {
assert_eq!(res.data.unwrap(), expected_response_data);
}
other => panic!("expected graphql response, found {:?}", other),
}
}

#[tokio::test]
async fn it_rejects_non_preflighted_headers_request() {
let mock = MockRouterService::new().build();

let service_stack = Csrf::new(CSRFConfig { disabled: false })
.await
.unwrap()
.router_service(mock.boxed());

let non_preflighted_request = RouterRequest::fake_builder().build().unwrap();

let res = service_stack
.oneshot(non_preflighted_request)
.await
.unwrap();

match res.response.into_body() {
ResponseBody::GraphQL(res) => {
assert_eq!(res.errors[0].message, "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). \
Please either specify a 'content-type' header (with a mime-type that is not one of application/x-www-form-urlencoded,multipart/form-data,text/plain) \
or provide a header such that the request is preflighted: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests");
}
other => panic!("expected graphql response, found {:?}", other),
}
}

#[tokio::test]
async fn it_rejects_non_preflighted_content_type_request() {
let mock = MockRouterService::new().build();

let service_stack = Csrf::new(CSRFConfig { disabled: false })
.await
.unwrap()
.router_service(mock.boxed());

let non_preflighted_request = RouterRequest::fake_builder()
.headers(
[("content-type".into(), "text/plain".into())]
.into_iter()
.collect(),
)
.build()
.unwrap();

let res = service_stack
.oneshot(non_preflighted_request)
.await
.unwrap();

match res.response.into_body() {
ResponseBody::GraphQL(res) => {
assert_eq!(res.errors[0].message, "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). \
Please either specify a 'content-type' header (with a mime-type that is not one of application/x-www-form-urlencoded,multipart/form-data,text/plain) \
or provide a header such that the request is preflighted: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests");
}
other => panic!("expected graphql response, found {:?}", other),
}
}

#[tokio::test]
async fn it_accepts_non_preflighted_headers_request_when_plugin_is_disabled() {
let expected_response_data = json!({ "test": 1234 });
let expected_response_data2 = expected_response_data.clone();
let mut mock_service = MockRouterService::new();
mock_service.expect_call().times(1).returning(move |_| {
RouterResponse::fake_builder()
.data(expected_response_data2.clone())
.build()
});

let mock = mock_service.build();

let service_stack = Csrf::new(CSRFConfig { disabled: true })
.await
.unwrap()
.router_service(mock.boxed());

let non_preflighted_request = RouterRequest::fake_builder().build().unwrap();

let res = service_stack
.oneshot(non_preflighted_request)
.await
.unwrap();

match res.response.into_body() {
ResponseBody::GraphQL(res) => {
assert_eq!(res.data.unwrap(), expected_response_data);
}
other => panic!("expected graphql response, found {:?}", other),
}
}
}
1 change: 1 addition & 0 deletions apollo-router-core/src/plugins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//!
//! These plugins are compiled into the router and configured via YAML configuration.

mod csrf;
mod forbid_mutations;
mod headers;
mod include_subgraph_errors;
Expand Down
2 changes: 2 additions & 0 deletions apollo-router/src/configuration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ fn default_cors_headers() -> Vec<String> {
"Content-Type".into(),
"apollographql-client-name".into(),
"apollographql-client-version".into(),
"x-apollo-operation-name".into(),
o0Ignition0o marked this conversation as resolved.
Show resolved Hide resolved
"apollo-require-preflight".into(),
]
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
---
source: apollo-router/src/configuration/mod.rs
assertion_line: 621
expression: "&schema"
---
{
Expand All @@ -9,6 +8,16 @@ expression: "&schema"
"description": "The configuration for the router. Currently maintains a mapping of subgraphs.",
"type": "object",
"properties": {
"csrf": {
"type": "object",
"properties": {
"disabled": {
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
},
"forbid_mutations": {
"type": "boolean"
},
Expand Down Expand Up @@ -352,7 +361,9 @@ expression: "&schema"
"default": [
"Content-Type",
"apollographql-client-name",
"apollographql-client-version"
"apollographql-client-version",
"x-apollo-operation-name",
"apollo-require-preflight"
],
"type": "array",
"items": {
Expand Down