-
Notifications
You must be signed in to change notification settings - Fork 500
fix(http): add host check #764
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
Merged
+279
−2
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ use std::{convert::Infallible, fmt::Display, sync::Arc, time::Duration}; | |
|
|
||
| use bytes::Bytes; | ||
| use futures::{StreamExt, future::BoxFuture}; | ||
| use http::{Method, Request, Response, header::ALLOW}; | ||
| use http::{HeaderMap, Method, Request, Response, header::ALLOW}; | ||
| use http_body::Body; | ||
| use http_body_util::{BodyExt, Full, combinators::BoxBody}; | ||
| use tokio_stream::wrappers::ReceiverStream; | ||
|
|
@@ -29,8 +29,8 @@ use crate::{ | |
| }, | ||
| }; | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| #[non_exhaustive] | ||
| #[derive(Debug, Clone)] | ||
| pub struct StreamableHttpServerConfig { | ||
| /// The ping message duration for SSE connections. | ||
| pub sse_keep_alive: Option<Duration>, | ||
|
|
@@ -49,6 +49,16 @@ pub struct StreamableHttpServerConfig { | |
| /// When this token is cancelled, all active sessions are terminated and | ||
| /// the server stops accepting new requests. | ||
| pub cancellation_token: CancellationToken, | ||
| /// Allowed hostnames or `host:port` authorities for inbound `Host` validation. | ||
| /// | ||
| /// By default, Streamable HTTP servers only accept loopback hosts to | ||
| /// prevent DNS rebinding attacks against locally running servers. Public | ||
| /// deployments should override this list with their own hostnames. | ||
| /// examples: | ||
| /// allowed_hosts = ["localhost", "127.0.0.1", "0.0.0.0"] | ||
| /// or with ports: | ||
| /// allowed_hosts = ["example.com", "example.com:8080"] | ||
| pub allowed_hosts: Vec<String>, | ||
| } | ||
|
|
||
| impl Default for StreamableHttpServerConfig { | ||
|
|
@@ -59,11 +69,24 @@ impl Default for StreamableHttpServerConfig { | |
| stateful_mode: true, | ||
| json_response: false, | ||
| cancellation_token: CancellationToken::new(), | ||
| allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()], | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl StreamableHttpServerConfig { | ||
| pub fn with_allowed_hosts( | ||
| mut self, | ||
| allowed_hosts: impl IntoIterator<Item = impl Into<String>>, | ||
| ) -> Self { | ||
| self.allowed_hosts = allowed_hosts.into_iter().map(Into::into).collect(); | ||
| self | ||
| } | ||
| /// Disable allowed hosts. This will allow requests with any `Host` header, which is NOT recommended for public deployments. | ||
| pub fn disable_allowed_hosts(mut self) -> Self { | ||
| self.allowed_hosts.clear(); | ||
| self | ||
| } | ||
| pub fn with_sse_keep_alive(mut self, duration: Option<Duration>) -> Self { | ||
| self.sse_keep_alive = duration; | ||
| self | ||
|
|
@@ -130,6 +153,97 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box | |
| Ok(()) | ||
| } | ||
|
|
||
| fn forbidden_response(message: impl Into<String>) -> BoxResponse { | ||
| Response::builder() | ||
| .status(http::StatusCode::FORBIDDEN) | ||
| .body(Full::new(Bytes::from(message.into())).boxed()) | ||
| .expect("valid response") | ||
| } | ||
|
|
||
| fn normalize_host(host: &str) -> String { | ||
| host.trim_matches('[') | ||
| .trim_matches(']') | ||
| .to_ascii_lowercase() | ||
| } | ||
|
jokemanfire marked this conversation as resolved.
|
||
|
|
||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| struct NormalizedAuthority { | ||
| host: String, | ||
| port: Option<u16>, | ||
| } | ||
|
|
||
| fn normalize_authority(host: &str, port: Option<u16>) -> NormalizedAuthority { | ||
| NormalizedAuthority { | ||
| host: normalize_host(host), | ||
| port, | ||
| } | ||
| } | ||
|
|
||
| fn parse_allowed_authority(allowed: &str) -> Option<NormalizedAuthority> { | ||
| let allowed = allowed.trim(); | ||
| if allowed.is_empty() { | ||
| return None; | ||
| } | ||
|
|
||
| if let Ok(authority) = http::uri::Authority::try_from(allowed) { | ||
| return Some(normalize_authority(authority.host(), authority.port_u16())); | ||
| } | ||
|
|
||
| Some(normalize_authority(allowed, None)) | ||
| } | ||
|
|
||
| fn host_is_allowed(host: &NormalizedAuthority, allowed_hosts: &[String]) -> bool { | ||
| if allowed_hosts.is_empty() { | ||
| // If the allowed hosts list is empty, allow all hosts (not recommended). | ||
| return true; | ||
| } | ||
| allowed_hosts | ||
| .iter() | ||
| .filter_map(|allowed| parse_allowed_authority(allowed)) | ||
| .any(|allowed| { | ||
| allowed.host == host.host | ||
| && match allowed.port { | ||
| Some(port) => host.port == Some(port), | ||
| None => true, | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| fn bad_request_response(message: &str) -> BoxResponse { | ||
| let body = Full::from(message.to_string()).boxed(); | ||
|
|
||
| http::Response::builder() | ||
| .status(http::StatusCode::BAD_REQUEST) | ||
| .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8") | ||
| .body(body) | ||
| .expect("failed to build bad request response") | ||
| } | ||
|
|
||
| fn parse_host_header(headers: &HeaderMap) -> Result<NormalizedAuthority, BoxResponse> { | ||
| let Some(host) = headers.get(http::header::HOST) else { | ||
| return Err(bad_request_response("Bad Request: missing Host header")); | ||
| }; | ||
|
Comment on lines
+223
to
+225
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 got this error when sending requests via HTTP/2 with axum nesting service. H2 does not use |
||
|
|
||
| let host = host | ||
| .to_str() | ||
| .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?; | ||
| let authority = http::uri::Authority::try_from(host) | ||
| .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?; | ||
| Ok(normalize_authority(authority.host(), authority.port_u16())) | ||
| } | ||
|
|
||
| fn validate_dns_rebinding_headers( | ||
| headers: &HeaderMap, | ||
| config: &StreamableHttpServerConfig, | ||
| ) -> Result<(), BoxResponse> { | ||
| let host = parse_host_header(headers)?; | ||
| if !host_is_allowed(&host, &config.allowed_hosts) { | ||
| return Err(forbidden_response("Forbidden: Host header is not allowed")); | ||
| } | ||
|
|
||
| Ok(()) | ||
|
jokemanfire marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// # Streamable HTTP server | ||
| /// | ||
| /// An HTTP service that implements the | ||
|
|
@@ -279,6 +393,9 @@ where | |
| B: Body + Send + 'static, | ||
| B::Error: Display, | ||
| { | ||
| if let Err(response) = validate_dns_rebinding_headers(request.headers(), &self.config) { | ||
| return response; | ||
| } | ||
| let method = request.method().clone(); | ||
| let allowed_methods = match self.config.stateful_mode { | ||
| true => "GET, POST, DELETE", | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Adding the new
pub allowed_hostsfield toStreamableHttpServerConfigis a semver-breaking change for downstream users constructing the config via struct literals (they will now fail to compile). If this is intended, it should be paired with a major version bump / explicit release note; otherwise consider making the config#[non_exhaustive]and/or moving toward a constructor/builder pattern to avoid future breakage.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.
This is especially important given that PR #715 was recently merged to prepare for 1.0 stable release.
StreamableHttpServerConfigmay have been missed in that effort. Adding#[non_exhaustive]here would be consistent with that direction and prevent this class of breakage going forward.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 this security update should be introduced in 1.0 version , and I will add the #[non_exhaustive].