diff --git a/Cargo.lock b/Cargo.lock
index 2f6290db8..0b75db73f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2044,6 +2044,7 @@ dependencies = [
"tracing-serde",
"tracing-subscriber",
"url",
+ "urlencoding",
"users",
"uuid 1.1.2",
"validator",
@@ -2078,6 +2079,7 @@ dependencies = [
"serde_json",
"time 0.2.27",
"url",
+ "urlencoding",
"uuid 1.1.2",
"webauthn-rs",
]
@@ -4169,6 +4171,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821"
+
[[package]]
name = "users"
version = "0.11.0"
diff --git a/Makefile b/Makefile
index ec559d395..4444f0c24 100644
--- a/Makefile
+++ b/Makefile
@@ -142,9 +142,8 @@ docs/pykanidm/build:
poetry install && \
poetry run mkdocs build
-
docs/pykanidm/serve: ## Run the local mkdocs server
docs/pykanidm/serve:
cd pykanidm && \
poetry install && \
- poetry run mkdocs serve
\ No newline at end of file
+ poetry run mkdocs serve
diff --git a/artwork/kani-waving.svg b/artwork/kani-waving.svg
new file mode 100644
index 000000000..d6ff475d5
--- /dev/null
+++ b/artwork/kani-waving.svg
@@ -0,0 +1,168 @@
+
+
+
+
diff --git a/kanidm_proto/Cargo.toml b/kanidm_proto/Cargo.toml
index 83535b93e..63d6727fb 100644
--- a/kanidm_proto/Cargo.toml
+++ b/kanidm_proto/Cargo.toml
@@ -19,4 +19,4 @@ webauthn-rs = { version = "^0.3.2", default-features = false, features = ["wasm"
# Can not upgrade due to breaking timezone apis.
time = { version = "=0.2.27", features = ["serde", "std"] }
url = { version = "^2.2.2", features = ["serde"] }
-
+urlencoding = "2.1.0"
diff --git a/kanidm_proto/src/v1.rs b/kanidm_proto/src/v1.rs
index 0bbd55cf1..a1272511b 100644
--- a/kanidm_proto/src/v1.rs
+++ b/kanidm_proto/src/v1.rs
@@ -792,22 +792,13 @@ pub struct TotpSecret {
impl TotpSecret {
///
pub fn to_uri(&self) -> String {
- // label = accountname / issuer (“:” / “%3A”) *”%20” accountname
- // This is already done server side but paranoia is good!
- let accountname = self
- .accountname
- .replace(':', "")
- .replace("%3A", "")
- .replace(' ', "%20");
- let issuer = self
- .issuer
- .replace(':', "")
- .replace("%3A", "")
- .replace(' ', "%20");
+ let accountname = urlencoding::Encoded(&self.accountname);
+ let issuer = urlencoding::Encoded(&self.issuer);
let label = format!("{}:{}", issuer, accountname);
let algo = self.algo.to_string();
let secret = self.get_secret();
let period = self.step;
+
format!(
"otpauth://totp/{}?secret={}&issuer={}&algorithm={}&digits=6&period={}",
label, secret, issuer, algo, period
@@ -991,6 +982,7 @@ mod tests {
algo: TotpAlgo::Sha256,
};
let s = totp.to_uri();
- assert!(s == "otpauth://totp/blackhats%20australia:william?secret=VK54ZXI&issuer=blackhats%20australia&algorithm=SHA256&digits=6&period=30");
+ println!("{}", s);
+ assert!(s == "otpauth://totp/blackhats%20australia:william%3A%253A?secret=VK54ZXI&issuer=blackhats%20australia&algorithm=SHA256&digits=6&period=30");
}
}
diff --git a/kanidmd/idm/Cargo.toml b/kanidmd/idm/Cargo.toml
index bce183934..3844cd8b4 100644
--- a/kanidmd/idm/Cargo.toml
+++ b/kanidmd/idm/Cargo.toml
@@ -57,6 +57,7 @@ tracing = { version = "^0.1.35", features = ["attributes"] }
tracing-serde = "^0.1.3"
tracing-subscriber = { version = "^0.3.14", features = ["env-filter"] }
url = { version = "^2.2.2", features = ["serde"] }
+urlencoding = "2.1.0"
uuid = { version = "^1.1.2", features = ["serde", "v4" ] }
validator = { version = "^0.15.0", features = ["phone"] }
webauthn-rs = "^0.3.2"
diff --git a/kanidmd/idm/src/credential/totp.rs b/kanidmd/idm/src/credential/totp.rs
index 52be10226..e70a7a131 100644
--- a/kanidmd/idm/src/credential/totp.rs
+++ b/kanidmd/idm/src/credential/totp.rs
@@ -165,14 +165,8 @@ impl Totp {
pub fn to_proto(&self, accountname: &str, issuer: &str) -> ProtoTotp {
ProtoTotp {
- accountname: accountname
- .replace(":", "")
- .replace("%3A", "")
- .replace(" ", "%20"),
- issuer: issuer
- .replace(":", "")
- .replace("%3A", "")
- .replace(" ", "%20"),
+ accountname: accountname.to_string(),
+ issuer: issuer.to_string(),
secret: self.secret.clone(),
step: self.step,
algo: match self.algo {
diff --git a/kanidmd/score/src/https/manifest.rs b/kanidmd/score/src/https/manifest.rs
index f4d271d66..f12f25a7e 100644
--- a/kanidmd/score/src/https/manifest.rs
+++ b/kanidmd/score/src/https/manifest.rs
@@ -68,7 +68,7 @@ enum Direction {
/// Display modes from the Web app manifest definition
///
-/// Ref: https://developer.mozilla.org/en-US/docs/Web/Manifest/display
+/// Ref:
#[derive(Debug, Clone, Serialize, Deserialize)]
enum DisplayMode {
/// All of the available display area is used and no user agent chrome is
@@ -126,11 +126,15 @@ pub async fn manifest(req: tide::Request) -> tide::Result {
},
];
+ let start_url = match req.host() {
+ Some(value) => format!("https://{}/", value).clone(),
+ None => String::from("/"),
+ };
let manifest_struct = Manifest {
- short_name: domain_display_name.as_str(),
+ short_name: "Kanidm",
name: domain_display_name.as_str(),
- start_url: "/", // TODO: this needs to be the frontend URL, can't get this yet
+ start_url: start_url.as_str(),
display_mode: DisplayMode::MinimalUi,
description: None,
orientation: None,
diff --git a/kanidmd/score/src/https/middleware.rs b/kanidmd/score/src/https/middleware.rs
index 643403b97..4afa9fb81 100644
--- a/kanidmd/score/src/https/middleware.rs
+++ b/kanidmd/score/src/https/middleware.rs
@@ -1,16 +1,159 @@
+///! Custom tide middleware for Kanidm
+use crate::https::JavaScriptFile;
+use regex::Regex;
+
+/// This is for the tide_compression middleware so that we only compress certain content types.
+///
+/// ```
+/// use score::https::middleware::compression_content_type_checker;
+/// let these_should_match = vec![
+/// "application/wasm",
+/// "text/json",
+/// "text/javascript"
+/// ];
+/// for test_value in these_should_match {
+/// eprintln!("checking {:?}", test_value);
+/// assert!(compression_content_type_checker().is_match(test_value));
+/// }
+/// assert!(compression_content_type_checker().is_match("application/wasm"));
+/// let these_should_fail = vec![
+/// "image/jpeg",
+/// "image/wasm",
+/// "text/html",
+/// ];
+/// for test_value in these_should_fail {
+/// eprintln!("checking {:?}", test_value);
+/// assert!(!compression_content_type_checker().is_match(test_value));
+/// }
+/// ```
+pub fn compression_content_type_checker() -> Regex {
+ Regex::new(r"^(?:(image/svg\+xml)|(?:application|text)/(?:css|javascript|json|text|xml|wasm))$")
+ .expect("regex matcher for tide_compress content-type check failed to compile")
+}
+
+#[derive(Default)]
+pub struct CacheableMiddleware;
+
+#[async_trait::async_trait]
+impl tide::Middleware for CacheableMiddleware {
+ async fn handle(
+ &self,
+ request: tide::Request,
+ next: tide::Next<'_, State>,
+ ) -> tide::Result {
+ let mut response = next.run(request).await;
+ response.insert_header("Cache-Control", "max-age=60,must-revalidate,private");
+ Ok(response)
+ }
+}
+
+#[derive(Default)]
+pub struct NoCacheMiddleware;
+
+#[async_trait::async_trait]
+impl tide::Middleware for NoCacheMiddleware {
+ async fn handle(
+ &self,
+ request: tide::Request,
+ next: tide::Next<'_, State>,
+ ) -> tide::Result {
+ let mut response = next.run(request).await;
+ response.insert_header("Cache-Control", "no-store, max-age=0");
+ response.insert_header("Pragma", "no-cache");
+ Ok(response)
+ }
+}
+
+#[derive(Default)]
+/// Sets Cache-Control headers on static content endpoints
+pub struct StaticContentMiddleware;
+
+#[async_trait::async_trait]
+impl tide::Middleware for StaticContentMiddleware {
+ async fn handle(
+ &self,
+ request: tide::Request,
+ next: tide::Next<'_, State>,
+ ) -> tide::Result {
+ let mut response = next.run(request).await;
+ response.insert_header("Cache-Control", "max-age=3600,private");
+ Ok(response)
+ }
+}
+
+#[derive(Default)]
+/// Adds the folloing headers to responses
+/// - x-frame-options
+/// - x-content-type-options
+/// - cross-origin-resource-policy
+/// - cross-origin-embedder-policy
+/// - cross-origin-opener-policy
+pub struct StrictResponseMiddleware;
+
+#[async_trait::async_trait]
+impl tide::Middleware for StrictResponseMiddleware {
+ async fn handle(
+ &self,
+ request: tide::Request,
+ next: tide::Next<'_, State>,
+ ) -> tide::Result {
+ let mut response = next.run(request).await;
+ response.insert_header("cross-origin-embedder-policy", "require-corp");
+ response.insert_header("cross-origin-opener-policy", "same-origin");
+ response.insert_header("cross-origin-resource-policy", "same-origin");
+ response.insert_header("x-content-type-options", "nosniff");
+ response.insert_header("x-frame-options", "deny");
+ Ok(response)
+ }
+}
+struct StrictRequestMiddleware;
+
+impl Default for StrictRequestMiddleware {
+ fn default() -> Self {
+ StrictRequestMiddleware {}
+ }
+}
+
+#[async_trait::async_trait]
+impl tide::Middleware for StrictRequestMiddleware {
+ async fn handle(
+ &self,
+ request: tide::Request,
+ next: tide::Next<'_, State>,
+ ) -> tide::Result {
+ let proceed = request
+ .header("sec-fetch-site")
+ .map(|hv| {
+ matches!(hv.as_str(), "same-origin" | "same-site" | "none")
+ || (request.header("sec-fetch-mode").map(|v| v.as_str()) == Some("navigate")
+ && request.method() == tide::http::Method::Get
+ && request.header("sec-fetch-dest").map(|v| v.as_str()) != Some("object")
+ && request.header("sec-fetch-dest").map(|v| v.as_str()) != Some("embed"))
+ })
+ .unwrap_or(true);
+
+ if proceed {
+ Ok(next.run(request).await)
+ } else {
+ Err(tide::Error::from_str(
+ tide::StatusCode::MethodNotAllowed,
+ "StrictRequestViolation",
+ ))
+ }
+ }
+}
+
#[derive(Default)]
/// This tide MiddleWare adds headers like Content-Security-Policy
/// and similar families. If it keeps adding more things then
/// probably rename the middleware :)
pub struct UIContentSecurityPolicyResponseMiddleware {
// The sha384 hash of /pkg/wasmloader.js
- pub integrity_wasmloader: String,
+ pub hashes: Vec,
}
impl UIContentSecurityPolicyResponseMiddleware {
- pub fn new(integrity_wasmloader: String) -> Self {
- return Self {
- integrity_wasmloader,
- };
+ pub fn new(hashes: Vec) -> Self {
+ return Self { hashes };
}
}
@@ -26,10 +169,13 @@ impl tide::Middleware
) -> tide::Result {
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()));
+ // a list of hashes of js files that we're sending to the user
+ let hashes: Vec = self
+ .hashes
+ .iter()
+ .map(|j| format!("'{}'", j.hash))
+ .collect();
+
response.insert_header(
/* content-security-policy headers tell the browser what to trust
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
@@ -39,16 +185,12 @@ impl tide::Middleware
we should be loading, and should be really secure about that!
*/
- // TODO: consider scraping the other js files that wasm-pack builds and including them too
"content-security-policy",
vec![
"default-src 'self'",
+ // TODO: #912 have a dev/test mode where we can rebuild the hashes on page load, so when doing constant JS changes/rebuilds we don't have to restart the server every time. It'd be *terrible* to run in prod because of the constant disk thrashing, but nicer for devs.
// we need unsafe-eval because of WASM things
- format!(
- "script-src 'self' 'sha384-{}' 'unsafe-eval'",
- self.integrity_wasmloader.as_str()
- )
- .as_str(),
+ format!("script-src 'self' {} 'unsafe-eval'", hashes.join(" ")).as_str(),
"form-action https: 'self'", // to allow for OAuth posts
// we are not currently using workers so it can be blocked
"worker-src 'none'",
diff --git a/kanidmd/score/src/https/mod.rs b/kanidmd/score/src/https/mod.rs
index 1d32915c5..bd2ecdf99 100644
--- a/kanidmd/score/src/https/mod.rs
+++ b/kanidmd/score/src/https/mod.rs
@@ -1,5 +1,5 @@
mod manifest;
-mod middleware;
+pub mod middleware;
mod oauth2;
mod v1;
@@ -15,7 +15,6 @@ use kanidm::config::{ServerRole, TlsConfiguration};
use kanidm::prelude::*;
use kanidm::status::StatusActor;
use kanidm::tracing_tree::TreeMiddleware;
-use regex::Regex;
use serde::Serialize;
use std::fs::canonicalize;
use std::path::PathBuf;
@@ -25,6 +24,35 @@ use tide_openssl::TlsListener;
use tracing::{error, info};
use uuid::Uuid;
+#[derive(Clone)]
+pub struct JavaScriptFile {
+ // Relative to the pkg/ dir
+ filepath: &'static str,
+ // SHA384 hash of the file
+ hash: String,
+ // if it's a module add the "type"
+ filetype: Option,
+}
+
+impl JavaScriptFile {
+ /// return the hash for use in CSP headers
+ pub fn as_csp_hash(self) -> String {
+ self.hash
+ }
+
+ /// returns a "#,
+ self.filepath, self.hash, typeattr,
+ )
+ }
+}
+
#[derive(Clone)]
pub struct AppState {
pub status_ref: &'static StatusActor,
@@ -33,36 +61,10 @@ pub struct AppState {
// Store the token management parts.
pub jws_signer: std::sync::Arc,
pub jws_validator: std::sync::Arc,
+ /// The SHA384 hashes of javascript files we're going to serve to users
+ pub js_files: Vec,
}
-/// This is for the tide_compression middleware so that we only compress certain content types.
-///
-/// ```
-/// use score::https::compression_content_type_checker;
-/// let these_should_match = vec![
-/// "application/wasm",
-/// "text/json",
-/// "text/javascript"
-/// ];
-/// for test_value in these_should_match {
-/// eprintln!("checking {:?}", test_value);
-/// assert!(compression_content_type_checker().is_match(test_value));
-/// }
-/// assert!(compression_content_type_checker().is_match("application/wasm"));
-/// let these_should_fail = vec![
-/// "image/jpeg",
-/// "image/wasm",
-/// "text/html",
-/// ];
-/// for test_value in these_should_fail {
-/// eprintln!("checking {:?}", test_value);
-/// assert!(!compression_content_type_checker().is_match(test_value));
-/// }
-/// ```
-pub fn compression_content_type_checker() -> Regex {
- Regex::new(r"^(?:(image/svg\+xml)|(?:application|text)/(?:css|javascript|json|text|xml|wasm))$")
- .expect("regex matcher for tide_compress content-type check failed to compile")
-}
pub trait RequestExtensions {
fn get_current_uat(&self) -> Option;
@@ -182,7 +184,7 @@ pub fn to_tide_response(
})
}
-/// Returns a generic robots.txt
+/// Returns a generic robots.txt blocking all bots
async fn robots_txt(_req: tide::Request) -> tide::Result {
let mut res = tide::Response::new(200);
@@ -205,6 +207,15 @@ async fn index_view(req: tide::Request) -> tide::Result {
res.insert_header("X-KANIDM-OPID", hvalue);
res.set_content_type("text/html;charset=utf-8");
+ // this feels icky but I felt that adding a trait on Vec which generated the string was going a bit far
+ let jsfiles: Vec = req
+ .state()
+ .to_owned()
+ .js_files
+ .into_iter()
+ .map(|j| j.as_tag())
+ .collect();
+ let jstags = jsfiles.join(" ");
res.set_body(format!(r#"
@@ -223,8 +234,8 @@ async fn index_view(req: tide::Request) -> tide::Result {
-
-
+ {}
+
@@ -239,125 +250,16 @@ async fn index_view(req: tide::Request) -> tide::Result {
-