-
Notifications
You must be signed in to change notification settings - Fork 305
feat: CSRF plugin #1006
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
Changes from 1 commit
482d1a7
331a5e1
a76847c
ff83523
5f75844
579eac0
00cf251
00769c2
03ca032
d4afa38
19f96a2
1759abb
669b786
c4f101f
ba41af3
95c3526
6622d4c
4d27490
a20105b
b35ed95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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)) | ||
} | ||
|
||
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 commentThe 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 So let's say you tried to send a request with
The browser would combine this to
which is MIME type with essence 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. https://fetch.spec.whatwg.org/#cors-safelisted-request-header claims 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:
it also claims 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 commentThe 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 commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe 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>() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 But worse is that it seems to only honor spaces, not tabs. So it chokes on 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.