Skip to content

Commit ef52553

Browse files
fix: added host header check (to protect against DNS rebinding attacks) (#250)
Co-authored-by: Johan Bjäreholt <johan@bjareho.lt>
1 parent 318dd25 commit ef52553

File tree

5 files changed

+272
-15
lines changed

5 files changed

+272
-15
lines changed

aw-server/src/endpoints/hostcheck.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//! Host header check needs to be performed to protect against DNS poisoning
2+
//! attacks[1].
3+
//!
4+
//! Uses a Request Fairing to intercept the request before it's handled.
5+
//! If the Host header is not valid, the request will be rerouted to a
6+
//! BadRequest
7+
//!
8+
//! [1]: https://github.com/ActivityWatch/activitywatch/security/advisories/GHSA-v9fg-6g9j-h4x4
9+
use rocket::fairing::Fairing;
10+
use rocket::handler::Outcome;
11+
use rocket::http::uri::Origin;
12+
use rocket::http::{Method, Status};
13+
use rocket::{Data, Request, Rocket, Route};
14+
15+
use crate::config::AWConfig;
16+
use crate::endpoints::HttpErrorJson;
17+
18+
static FAIRING_ROUTE_BASE: &str = "/checkheader_fairing";
19+
20+
pub struct HostCheck {
21+
validate: bool,
22+
}
23+
24+
impl HostCheck {
25+
pub fn new(config: &AWConfig) -> HostCheck {
26+
// We only validate requests if the server binds a local address
27+
let validate = config.address == "127.0.0.1" || config.address == "localhost";
28+
HostCheck { validate }
29+
}
30+
}
31+
32+
/// Route for HostCheck Fairing error
33+
fn fairing_error_route<'r>(req: &'r Request<'_>, _: Data) -> Outcome<'r> {
34+
let err = HttpErrorJson::new(Status::BadRequest, "Host header is invalid".to_string());
35+
Outcome::from(req, err)
36+
}
37+
38+
/// Create a new `Route` for Fairing handling
39+
fn fairing_route() -> Route {
40+
Route::ranked(1, Method::Get, "/", fairing_error_route)
41+
}
42+
43+
fn redirect_bad_request(request: &mut Request) {
44+
let uri = FAIRING_ROUTE_BASE.to_string();
45+
let origin = Origin::parse_owned(uri).unwrap();
46+
request.set_method(Method::Get);
47+
request.set_uri(origin);
48+
}
49+
50+
impl Fairing for HostCheck {
51+
fn info(&self) -> rocket::fairing::Info {
52+
rocket::fairing::Info {
53+
name: "HostCheck",
54+
kind: rocket::fairing::Kind::Attach | rocket::fairing::Kind::Request,
55+
}
56+
}
57+
58+
fn on_attach(&self, rocket: Rocket) -> Result<Rocket, Rocket> {
59+
match self.validate {
60+
true => Ok(rocket.mount(FAIRING_ROUTE_BASE, vec![fairing_route()])),
61+
false => {
62+
warn!("Host header validation is turned off, this is a security risk");
63+
Ok(rocket)
64+
}
65+
}
66+
}
67+
68+
fn on_request(&self, request: &mut Request, _: &Data) {
69+
if !self.validate {
70+
// host header check is disabled
71+
return;
72+
}
73+
74+
// Fetch header
75+
let hostheader_opt = request.headers().get_one("host");
76+
if hostheader_opt.is_none() {
77+
info!("Missing 'Host' header, denying request");
78+
redirect_bad_request(request);
79+
return;
80+
}
81+
82+
// Parse hostname from host header
83+
// hostname contains port, which we don't care about and filter out
84+
let hostheader = hostheader_opt.unwrap();
85+
let host_opt = hostheader.split(":").next();
86+
if host_opt.is_none() {
87+
info!("Host header '{}' not allowed, denying request", hostheader);
88+
redirect_bad_request(request);
89+
return;
90+
}
91+
92+
// Deny requests to hosts that are not localhost
93+
let valid_hosts: Vec<&str> = vec!["127.0.0.1", "localhost"];
94+
let host = host_opt.unwrap();
95+
if !valid_hosts.contains(&host) {
96+
info!("Host header '{}' not allowed, denying request", hostheader);
97+
redirect_bad_request(request);
98+
}
99+
100+
// host header is verified, proceed with request
101+
}
102+
}
103+
104+
#[cfg(test)]
105+
mod tests {
106+
use std::path::PathBuf;
107+
use std::sync::Mutex;
108+
109+
use rocket::http::{ContentType, Header, Status};
110+
use rocket::Rocket;
111+
112+
use crate::config::AWConfig;
113+
use crate::endpoints;
114+
115+
fn setup_testserver(address: String) -> Rocket {
116+
let state = endpoints::ServerState {
117+
datastore: Mutex::new(aw_datastore::Datastore::new_in_memory(false)),
118+
asset_path: PathBuf::from("aw-webui/dist"),
119+
device_id: "test_id".to_string(),
120+
};
121+
let mut aw_config = AWConfig::default();
122+
aw_config.address = address;
123+
endpoints::build_rocket(state, aw_config)
124+
}
125+
126+
#[test]
127+
fn test_public_address() {
128+
let server = setup_testserver("0.0.0.0".to_string());
129+
let client = rocket::local::Client::new(server).expect("valid instance");
130+
131+
// When a public address is used, request should always pass, regardless
132+
// if the Host header is missing
133+
let res = client
134+
.get("/api/0/info")
135+
.header(ContentType::JSON)
136+
.dispatch();
137+
assert_eq!(res.status(), Status::Ok);
138+
}
139+
140+
#[test]
141+
fn test_localhost_address() {
142+
let server = setup_testserver("127.0.0.1".to_string());
143+
let client = rocket::local::Client::new(server).expect("valid instance");
144+
145+
// If Host header is missing we should get a BadRequest
146+
let res = client
147+
.get("/api/0/info")
148+
.header(ContentType::JSON)
149+
.dispatch();
150+
assert_eq!(res.status(), Status::BadRequest);
151+
152+
// If Host header is not 127.0.0.1 or localhost we should get BadRequest
153+
let res = client
154+
.get("/api/0/info")
155+
.header(ContentType::JSON)
156+
.header(Header::new("Host", "192.168.0.1:1234"))
157+
.dispatch();
158+
assert_eq!(res.status(), Status::BadRequest);
159+
160+
// If Host header is 127.0.0.1:5600 we should get OK
161+
let res = client
162+
.get("/api/0/info")
163+
.header(ContentType::JSON)
164+
.header(Header::new("Host", "127.0.0.1:5600"))
165+
.dispatch();
166+
assert_eq!(res.status(), Status::Ok);
167+
168+
// If Host header is localhost:5600 we should get OK
169+
let res = client
170+
.get("/api/0/info")
171+
.header(ContentType::JSON)
172+
.header(Header::new("Host", "localhost:5600"))
173+
.dispatch();
174+
assert_eq!(res.status(), Status::Ok);
175+
176+
// If Host header is missing port, we should still get OK
177+
let res = client
178+
.get("/api/0/info")
179+
.header(ContentType::JSON)
180+
.header(Header::new("Host", "localhost"))
181+
.dispatch();
182+
assert_eq!(res.status(), Status::Ok);
183+
}
184+
}

aw-server/src/endpoints/import.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ pub fn bucket_import_form(
5050
.params()
5151
.find(|&(k, _)| k == "boundary")
5252
.ok_or_else(|| {
53-
return HttpErrorJson::new(
53+
HttpErrorJson::new(
5454
Status::BadRequest,
5555
"`Content-Type: multipart/form-data` boundary param not provided".to_string(),
56-
);
56+
)
5757
})?;
5858

5959
let string = process_multipart_packets(boundary, data);

aw-server/src/endpoints/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ mod util;
2222
mod bucket;
2323
mod cors;
2424
mod export;
25+
mod hostcheck;
2526
mod import;
2627
mod query;
2728
mod settings;
@@ -78,8 +79,10 @@ pub fn build_rocket(server_state: ServerState, config: AWConfig) -> rocket::Rock
7879
config.address, config.port
7980
);
8081
let cors = cors::cors(&config);
82+
let hostcheck = hostcheck::HostCheck::new(&config);
8183
rocket::custom(config.to_rocket_config())
8284
.attach(cors.clone())
85+
.attach(hostcheck)
8386
.manage(cors)
8487
.manage(server_state)
8588
.manage(config)

aw-server/src/logging.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,16 @@ pub fn setup_logger(testing: bool) -> Result<(), fern::InitError> {
6060
Ok(())
6161
}
6262

63-
#[test]
64-
fn test_setup_logger() {
65-
setup_logger(true).unwrap();
63+
#[cfg(test)]
64+
mod tests {
65+
use super::setup_logger;
66+
67+
/* disable this test.
68+
* This is due to it failing in GitHub actions, claiming that the logger
69+
* has been initialized twice which is not allowed */
70+
#[ignore]
71+
#[test]
72+
fn test_setup_logger() {
73+
setup_logger(true).unwrap();
74+
}
6675
}

0 commit comments

Comments
 (0)