Adding Content-Security-Policy Headers and auto-generating integrity hashes (#740)

* Adding Content-Security-Policy Headers and auto-generating integrity hashes
* created favicon and WASM loader as their own files
* adding .map files from bootstrap
This commit is contained in:
James Hodgkinson 2022-05-06 14:20:52 +10:00 committed by GitHub
parent 12852cf0a0
commit d5fbb91a1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 205 additions and 28 deletions

View file

@ -152,33 +152,27 @@ pub fn to_tide_response<T: Serialize>(
// Handle the various end points we need to expose
async fn index_view(_req: tide::Request<AppState>) -> tide::Result {
let mut res = tide::Response::new(200);
res.set_content_type("text/html;charset=utf-8");
res.set_body(
r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Kanidm</title>
<link rel="stylesheet" href="/pkg/external/bootstrap.min.css" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"/>
<link rel="stylesheet" href="/pkg/style.css"/>
<script src="/pkg/external/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"></script>
<script src="/pkg/external/confetti.js"></script>
<script type="module" defer>
import init, { run_app } from '/pkg/kanidmd_web_ui.js';
async function main() {
await init('/pkg/kanidmd_web_ui_bg.wasm');
run_app();
}
main()
</script>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🦀</text></svg>" />
</head>
<body>
</body>
</html>
"#,
res.set_content_type("text/html;charset=utf-8");
res.set_body(r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Kanidm</title>
<link rel="stylesheet" href="/pkg/external/bootstrap.min.css" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"/>
<link rel="stylesheet" href="/pkg/style.css"/>
<script src="/pkg/external/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"></script>
<script src="/pkg/external/confetti.js"></script>
<script type="module" type="text/javascript" src="/pkg/wasmloader.js" integrity="sha384-==WASMHASH==">
</script>
<link rel="icon" href="/pkg/favicon.svg" />
</head>
<body>
</body>
</html>
"#,
);
Ok(res)
@ -252,6 +246,49 @@ impl<State: Clone + Send + Sync + 'static> tide::Middleware<State> for StrictRes
Ok(response)
}
}
#[derive(Default)]
struct UIContentSecurityPolicyResponseMiddleware {
// The sha384 hash of /pkg/wasmloader.js
pub integrity_wasmloader: String,
}
impl UIContentSecurityPolicyResponseMiddleware {
fn new(integrity_wasmloader: String) -> Self {
return Self {
integrity_wasmloader,
};
}
}
#[async_trait::async_trait]
impl<State: Clone + Send + Sync + 'static> tide::Middleware<State>
for UIContentSecurityPolicyResponseMiddleware
{
async fn handle(
&self,
request: tide::Request<State>,
next: tide::Next<'_, State>,
) -> tide::Result {
// This updates the UI body with the integrity hash value for the wasmloader.js file, and adds content-security-policy headers.
let mut response = next.run(request).await;
// grab the body we're intending to return at this point
let body_str = response.take_body().into_string().await?;
// update it with the hash
response.set_body(body_str.replace("==WASMHASH==", self.integrity_wasmloader.as_str()));
response.insert_header(
"content-security-policy",
format!(
"default-src https: self; img-src https: self; script-src https: 'sha384-{}' 'unsafe-eval' self;",
self.integrity_wasmloader.as_str(),
)
);
Ok(response)
}
}
struct StrictRequestMiddleware;
@ -290,6 +327,32 @@ impl<State: Clone + Send + Sync + 'static> tide::Middleware<State> for StrictReq
}
}
pub fn generate_integrity_hash(filename: String) -> Result<String, String> {
let wasm_filepath = PathBuf::from(filename);
match wasm_filepath.exists() {
false => {
return Err(format!(
"Can't find {:?} to generate file hash",
&wasm_filepath
));
}
true => {
let filecontents = match std::fs::read(&wasm_filepath) {
Ok(value) => value,
Err(error) => {
return Err(format!(
"Failed to read {:?}, skipping: {:?}",
wasm_filepath, error
));
}
};
let shasum =
openssl::hash::hash(openssl::hash::MessageDigest::sha384(), &filecontents).unwrap();
Ok(format!("{}", openssl::base64::encode_block(&shasum)))
}
}
}
// TODO: Add request limits.
pub fn create_https_server(
address: String,
@ -349,11 +412,18 @@ pub fn create_https_server(
let mut static_tserver = tserver.at("");
static_tserver.with(StaticContentMiddleware::default());
static_tserver.with(UIContentSecurityPolicyResponseMiddleware::new(
generate_integrity_hash(env!("KANIDM_WEB_UI_PKG_PATH").to_owned() + "/wasmloader.js")
.unwrap(),
));
static_tserver.at("/").get(index_view);
static_tserver.at("/ui/").get(index_view);
static_tserver.at("/ui/*").get(index_view);
static_tserver
let mut static_dir_tserver = tserver.at("");
static_dir_tserver.with(StaticContentMiddleware::default());
static_dir_tserver
.at("/pkg")
.serve_dir(env!("KANIDM_WEB_UI_PKG_PATH"))
.map_err(|e| {
@ -647,6 +717,9 @@ pub fn create_https_server(
let tlsl = TlsListener::new(address, x_ref);
*/
// adds Content-Security-Policy Headers when running in HTTPS
// TODO: make this only apply to /ui/
tokio::spawn(async move {
if let Err(e) = tserver.listen(tlsl).await {
error!(

View file

@ -10,7 +10,7 @@ use tokio::task;
pub const ADMIN_TEST_USER: &str = "admin";
pub const ADMIN_TEST_PASSWORD: &str = "integration test admin password";
static PORT_ALLOC: AtomicU16 = AtomicU16::new(18080);
pub static PORT_ALLOC: AtomicU16 = AtomicU16::new(18080);
fn is_free_port(port: u16) -> bool {
// TODO: Refactor to use `Result::is_err` in a future PR

View file

@ -0,0 +1,92 @@
use std::net::TcpStream;
use std::sync::atomic::Ordering;
mod common;
use crate::common::{ADMIN_TEST_PASSWORD, ADMIN_TEST_USER, PORT_ALLOC};
use kanidm::audit::LogLevel;
use kanidm::config::{Configuration, IntegrationTestConfig, ServerRole};
use kanidm::tracing_tree;
use score::create_server_core;
use tokio::task;
fn is_free_port(port: u16) -> bool {
// TODO: Refactor to use `Result::is_err` in a future PR
match TcpStream::connect(("0.0.0.0", port)) {
Ok(_) => false,
Err(_) => true,
}
}
#[tokio::test]
async fn test_https_middleware_headers() {
// tests stuff
let _ = tracing_tree::test_init();
let mut counter = 0;
let port = loop {
let possible_port = PORT_ALLOC.fetch_add(1, Ordering::SeqCst);
if is_free_port(possible_port) {
break possible_port;
}
counter += 1;
if counter >= 5 {
eprintln!("Unable to allocate port!");
assert!(false);
}
};
let int_config = Box::new(IntegrationTestConfig {
admin_user: ADMIN_TEST_USER.to_string(),
admin_password: ADMIN_TEST_PASSWORD.to_string(),
});
// Setup the config ...
let mut config = Configuration::new();
config.address = format!("127.0.0.1:{}", port);
config.secure_cookies = false;
config.integration_test_config = Some(int_config);
config.log_level = Some(LogLevel::Quiet as u32);
config.role = ServerRole::WriteReplica;
config.threads = 1;
create_server_core(config, false)
.await
.expect("failed to start server core");
// We have to yield now to guarantee that the tide elements are setup.
task::yield_now().await;
let addr = format!("http://127.0.0.1:{}/", port);
// here we test the /ui/ endpoint which should have the headers
let response = match reqwest::get(format!("{}ui/", &addr)).await {
Ok(value) => value,
Err(error) => {
panic!("Failed to query {:?} : {:#?}", addr, error);
}
};
eprintln!("response: {:#?}", response);
assert_eq!(response.status(), 200);
eprintln!(
"csp headers: {:#?}",
response.headers().get("content-security-policy")
);
assert_ne!(response.headers().get("content-security-policy"), None);
// here we test the /pkg/ endpoint which shouldn't have the headers
let response =
match reqwest::get(format!("{}pkg/external/bootstrap.bundle.min.js", &addr)).await {
Ok(value) => value,
Err(error) => {
panic!("Failed to query {:?} : {:#?}", addr, error);
}
};
eprintln!("response: {:#?}", response);
assert_eq!(response.status(), 200);
eprintln!(
"csp headers: {:#?}",
response.headers().get("content-security-policy")
);
assert_eq!(response.headers().get("content-security-policy"), None);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y=".9em" font-size="90">🦀</text>
</svg>

After

Width:  |  Height:  |  Size: 111 B

View file

@ -0,0 +1,7 @@
// loads the module which loads the WASM. It's loaders all the way down.
import init, { run_app } from '/pkg/kanidmd_web_ui.js';
async function main() {
await init('/pkg/kanidmd_web_ui_bg.wasm');
run_app();
}
main()