Tests for proxy sign-in, passwords, account settings and ipx user (#399)
Who gets in had no test above the token check: the router now runs in tests with a peer address, as the listener gives it. A name in the proxy's header signs in only from the trusted proxy (not from cloudflared's gateway or anyone else) and makes the first account an admin; with Access set, the name is the signed token's, and a token for another application, an expired one, garbage, a header without a token or a token without the header are refused, as is a good token not via Caddy. Wrong passwords and unknown names are 401 and the right one after them signs in with a session cookie; a forged one is refused. PATCH /api/me refuses an over-long block list and a malformed TypeSafe key before asking TypeSafe. The signing keys are read from a local stand-in for the team's certs, and a 503 or 404 is an error. ipx user add, list, passwd, rename and rm run against a scratch database: user_cmd takes its stdin as an argument, so a password can be given without a terminal, and access's fetch takes the certs address. There is no 429 for a flood of wrong passwords to test: that is #409. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,13 +38,14 @@ impl Keys {
|
||||
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
||||
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
||||
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
||||
self.verify_with(team, aud, token, || fetch(client, team)).await
|
||||
let url = certs(team);
|
||||
self.verify_with(team, aud, token, || fetch(client, &url)).await
|
||||
}
|
||||
|
||||
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
||||
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
||||
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
||||
match fetch(client, team).await {
|
||||
match fetch(client, &certs(team)).await {
|
||||
Ok(set) => self.store(set),
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||
}
|
||||
@@ -111,10 +112,14 @@ impl Keys {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||
let url = format!("https://{team}/cdn-cgi/access/certs");
|
||||
/// Where a team publishes its signing keys.
|
||||
fn certs(team: &str) -> String {
|
||||
format!("https://{team}/cdn-cgi/access/certs")
|
||||
}
|
||||
|
||||
async fn fetch(client: &reqwest::Client, url: &str) -> Result<JwkSet> {
|
||||
client
|
||||
.get(&url)
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
@@ -126,25 +131,25 @@ async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use jsonwebtoken::{EncodingKey, Header};
|
||||
|
||||
const TEAM: &str = "team.cloudflareaccess.com";
|
||||
const AUD: &str = "aud-tag";
|
||||
pub(crate) const TEAM: &str = "team.cloudflareaccess.com";
|
||||
pub(crate) const AUD: &str = "aud-tag";
|
||||
|
||||
fn jwks() -> JwkSet {
|
||||
serde_json::from_str(include_str!("../../../tests/data/access-test.jwks.json")).unwrap()
|
||||
}
|
||||
|
||||
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||
pub(crate) fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||
let mut h = Header::new(alg);
|
||||
h.kid = Some("k1".into());
|
||||
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||
jsonwebtoken::encode(&h, &claims, &k).unwrap()
|
||||
}
|
||||
|
||||
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||
pub(crate) fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||
let now = jsonwebtoken::get_current_timestamp() as i64;
|
||||
serde_json::json!({
|
||||
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||
@@ -152,13 +157,45 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
|
||||
pub(crate) const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der");
|
||||
const FORGER: &[u8] = include_bytes!("../../../tests/data/access-forger.der");
|
||||
|
||||
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||
}
|
||||
|
||||
/// Keys holding the test signer's, as if fetched from the team.
|
||||
pub(crate) fn keys() -> Keys {
|
||||
let keys = Keys::default();
|
||||
keys.store(jwks());
|
||||
keys
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_keys_are_read_from_the_teams_certs_and_a_failure_is_an_error() {
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/cdn-cgi/access/certs",
|
||||
axum::routing::get(|| async { include_str!("../../../tests/data/access-test.jwks.json") }),
|
||||
)
|
||||
.route("/down", axum::routing::get(|| async { axum::http::StatusCode::SERVICE_UNAVAILABLE }));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let at = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let set = fetch(&client, &format!("http://{at}/cdn-cgi/access/certs")).await.unwrap();
|
||||
assert_eq!(set.keys.len(), jwks().keys.len());
|
||||
let keys = Keys::default();
|
||||
keys.store(set);
|
||||
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "the fetched key verifies");
|
||||
|
||||
assert!(fetch(&client, &format!("http://{at}/down")).await.is_err(), "a 503 is not a key set");
|
||||
assert!(fetch(&client, &format!("http://{at}/nothing")).await.is_err(), "nor a 404");
|
||||
assert_eq!(certs(TEAM), "https://team.cloudflareaccess.com/cdn-cgi/access/certs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||
let keys = Keys::default();
|
||||
|
||||
@@ -104,11 +104,10 @@ pub(crate) enum UserCmd {
|
||||
|
||||
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
|
||||
/// listing.
|
||||
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
let read_password = || -> Result<String> {
|
||||
use std::io::Read;
|
||||
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::io::Read) -> Result<()> {
|
||||
let mut read_password = || -> Result<String> {
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
stdin.read_to_string(&mut buf)?;
|
||||
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
|
||||
if pw.is_empty() {
|
||||
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
|
||||
@@ -232,3 +231,50 @@ pub(crate) fn duration(secs: u64) -> String {
|
||||
s => format!("{}d", s / 86_400),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_are_added_renamed_given_a_password_and_removed() {
|
||||
let ctx = Arc::new(crate::test_util::test_ctx(crate::config::Config::default()).await);
|
||||
let user = |cmd, stdin: &'static str| user_cmd(&ctx, cmd, stdin.as_bytes());
|
||||
let add = |name: &str, no_password| UserCmd::Add { name: name.into(), admin: false, no_password };
|
||||
|
||||
user(add(" Ray ", false), "first secret\n").await.unwrap();
|
||||
let ray = ctx.db.user_by_name("ray").await.unwrap().expect("lower-cased and trimmed");
|
||||
assert!(ray.is_admin, "the first account runs the place");
|
||||
assert!(
|
||||
crate::auth::check_password("first secret".into(), ray.pass_hash.clone()).await,
|
||||
"the newline is not part of it"
|
||||
);
|
||||
user(add("sam", true), "").await.unwrap();
|
||||
let sam = ctx.db.user_by_name("sam").await.unwrap().unwrap();
|
||||
assert!(!sam.is_admin && sam.pass_hash.is_none(), "signs in through the proxy");
|
||||
|
||||
assert!(user(add("RAY", true), "").await.is_err(), "already exists");
|
||||
assert!(user(add(" ", true), "").await.is_err(), "a name is required");
|
||||
assert!(user(add("dee", false), "").await.is_err(), "no password on stdin");
|
||||
user(UserCmd::List, "").await.unwrap();
|
||||
|
||||
// A new password signs every browser out.
|
||||
let sid = crate::auth::new_session_token();
|
||||
ctx.db.create_session(ray.id, &sid).await.unwrap();
|
||||
user(UserCmd::Passwd { name: "ray".into() }, "second secret").await.unwrap();
|
||||
let ray = ctx.db.user_by_name("ray").await.unwrap().unwrap();
|
||||
assert!(crate::auth::check_password("second secret".into(), ray.pass_hash).await);
|
||||
assert!(ctx.db.session_user(&sid, 86_400).await.unwrap().is_none(), "signed out");
|
||||
assert!(user(UserCmd::Passwd { name: "nobody".into() }, "x").await.is_err());
|
||||
|
||||
let rename = |name: &str, new_name: &str| UserCmd::Rename { name: name.into(), new_name: new_name.into() };
|
||||
assert!(user(rename("sam", "ray"), "").await.is_err(), "taken");
|
||||
assert!(user(rename("sam", "a,b"), "").await.is_err(), "the proxy could never send it");
|
||||
user(rename("sam", "Sam@X.test"), "").await.unwrap();
|
||||
assert_eq!(ctx.db.user_by_name("sam@x.test").await.unwrap().map(|u| u.id), Some(sam.id), "same account");
|
||||
|
||||
user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.unwrap();
|
||||
assert!(ctx.db.user_by_name("sam@x.test").await.unwrap().is_none());
|
||||
assert!(user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.is_err(), "already gone");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ async fn main() -> Result<()> {
|
||||
catalogue::add(&ctx, &url, folder, keywords, category, list).await
|
||||
}
|
||||
Command::Rm { feed } => catalogue::rm(&ctx, &feed).await,
|
||||
Command::User { cmd } => cli::user_cmd(&ctx, cmd).await,
|
||||
Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await,
|
||||
Command::Import { file } => catalogue::import(&ctx, &file).await,
|
||||
Command::Export { file } => catalogue::export(&ctx, &file).await,
|
||||
_ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||
|
||||
@@ -1293,6 +1293,157 @@ impl IntoResponse for ApiError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::access::tests as access;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// The router as the daemon serves it, on a scratch database, with the test signer's keys.
|
||||
async fn app(cfg: crate::config::Config) -> (Router, Arc<Ctx>) {
|
||||
let ctx = Arc::new(crate::test_util::test_ctx(cfg).await);
|
||||
let state = WebState {
|
||||
ctx: ctx.clone(),
|
||||
cmds: mpsc::channel(8).0,
|
||||
events: broadcast::channel(8).0,
|
||||
access: Arc::new(access::keys()),
|
||||
playing: Default::default(),
|
||||
};
|
||||
(router(state), ctx)
|
||||
}
|
||||
|
||||
/// A request from `from`, as the listener would see its peer; the status, Set-Cookie and body.
|
||||
async fn ask(
|
||||
app: &Router,
|
||||
method: &str,
|
||||
uri: &str,
|
||||
from: &str,
|
||||
headers: &[(&str, &str)],
|
||||
body: Option<serde_json::Value>,
|
||||
) -> (u16, String, String) {
|
||||
let mut req = axum::http::Request::builder().method(method).uri(uri);
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, *v);
|
||||
}
|
||||
let req = match body {
|
||||
Some(b) => req.header("content-type", "application/json").body(axum::body::Body::from(b.to_string())),
|
||||
None => req.body(axum::body::Body::empty()),
|
||||
};
|
||||
let mut req = req.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(SocketAddr::new(from.parse().unwrap(), 40000)));
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let cookie = resp.headers().get(header::SET_COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, cookie, String::from_utf8_lossy(&body).into_owned())
|
||||
}
|
||||
|
||||
const CADDY: &str = "192.168.64.10";
|
||||
|
||||
fn proxied() -> crate::config::Config {
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.web.trusted_header = "Cf-Access-Authenticated-User-Email".into();
|
||||
cfg.web.trusted_proxies = vec![CADDY.into()];
|
||||
cfg.web.auto_create_users = true;
|
||||
cfg
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_name_is_taken_from_the_proxys_header_only_from_the_proxy() {
|
||||
let (app, _) = app(proxied()).await;
|
||||
let who = [("Cf-Access-Authenticated-User-Email", "Rays@SDF1.net")];
|
||||
let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &who, None).await;
|
||||
assert_eq!(s, 200, "{me}");
|
||||
let me: serde_json::Value = serde_json::from_str(&me).unwrap();
|
||||
assert_eq!((me["name"].as_str(), me["admin"].as_bool()), (Some("rays@sdf1.net"), Some(true)), "made, first");
|
||||
|
||||
// Anyone else able to reach the port could send the header and be whoever they liked.
|
||||
assert_eq!(ask(&app, "GET", "/api/me", "192.168.64.1", &who, None).await.0, 401, "cloudflared itself");
|
||||
assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &who, None).await.0, 401);
|
||||
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &[], None).await.0, 401, "from the proxy, vouching for nobody");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_access_set_the_name_is_the_signed_tokens_and_nothing_else() {
|
||||
let mut cfg = proxied();
|
||||
cfg.web.access_team = access::TEAM.into();
|
||||
cfg.web.access_aud = access::AUD.into();
|
||||
let (app, _) = app(cfg).await;
|
||||
let rs = jsonwebtoken::Algorithm::RS256;
|
||||
let good = access::token(access::SIGNER, rs, access::claims(access::AUD, 300));
|
||||
fn with(t: &str) -> [(&str, &str); 2] {
|
||||
[("Cf-Access-Authenticated-User-Email", "someone-else@x.test"), ("Cf-Access-Jwt-Assertion", t)]
|
||||
}
|
||||
|
||||
let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &with(&good), None).await;
|
||||
assert_eq!(s, 200, "{me}");
|
||||
assert!(me.contains(r#""name":"rays@sdf1.net""#), "the token's email, not the header's: {me}");
|
||||
|
||||
let other_app = access::token(access::SIGNER, rs, access::claims("another-app", 300));
|
||||
let expired = access::token(access::SIGNER, rs, access::claims(access::AUD, -3600));
|
||||
for (t, why) in
|
||||
[(other_app.as_str(), "another application's"), (expired.as_str(), "expired"), ("x.y.z", "garbage")]
|
||||
{
|
||||
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &with(t), None).await.0, 401, "{why}");
|
||||
}
|
||||
let header_only = [("Cf-Access-Authenticated-User-Email", "rays@sdf1.net")];
|
||||
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &header_only, None).await.0, 401, "no token");
|
||||
let token_only = [("Cf-Access-Jwt-Assertion", good.as_str())];
|
||||
assert_eq!(ask(&app, "GET", "/api/me", CADDY, &token_only, None).await.0, 401, "no header to switch it on");
|
||||
assert_eq!(
|
||||
ask(&app, "GET", "/api/me", "10.0.0.5", &with(&good), None).await.0,
|
||||
401,
|
||||
"a good token, not via Caddy"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_password_is_refused_and_the_right_one_after_it_signs_in() {
|
||||
let (app, ctx) = app(crate::config::Config::default()).await;
|
||||
let hash = crate::auth::hash_password("correct horse").unwrap();
|
||||
ctx.db.create_user("dee", Some(&hash), false).await.unwrap();
|
||||
let login = |pw: &str| serde_json::json!({ "name": "Dee", "password": pw });
|
||||
for _ in 0..3 {
|
||||
assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("wrong"))).await.0, 401);
|
||||
}
|
||||
let (s, _, _) = ask(
|
||||
&app,
|
||||
"POST",
|
||||
"/api/login",
|
||||
"10.0.0.5",
|
||||
&[],
|
||||
Some(serde_json::json!({ "name": "nobody", "password": "x" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, 401, "an unknown name has the same answer");
|
||||
let (s, cookie, _) = ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("correct horse"))).await;
|
||||
assert_eq!(s, 200);
|
||||
let session = cookie.split(';').next().unwrap();
|
||||
assert!(session.starts_with("ipx_session=") && cookie.contains("HttpOnly"), "{cookie}");
|
||||
let (s, _, me) = ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", session)], None).await;
|
||||
assert!(s == 200 && me.contains(r#""name":"dee""#), "{s} {me}");
|
||||
assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", "ipx_session=forged")], None).await.0, 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_block_list_and_the_typesafe_key_are_checked_before_they_are_saved() {
|
||||
let (app, ctx) = app(proxied()).await;
|
||||
let who = [("Cf-Access-Authenticated-User-Email", "dee@x.test")];
|
||||
let patch = |b: serde_json::Value| ask(&app, "PATCH", "/api/me", CADDY, &who, Some(b));
|
||||
|
||||
let many: Vec<String> = (0..501).map(|i| format!("w{i}")).collect();
|
||||
assert_eq!(patch(serde_json::json!({ "blocked": many })).await.0, 400, "501 words");
|
||||
assert_eq!(patch(serde_json::json!({ "blocked": ["x".repeat(201)] })).await.0, 400, "a 201-character word");
|
||||
assert_eq!(patch(serde_json::json!({ "blocked": [" Spoiler ", "spoiler", "", "ads"] })).await.0, 204);
|
||||
let me = ctx.db.user_by_name("dee@x.test").await.unwrap().unwrap();
|
||||
assert_eq!(ctx.db.blocklist(me.id, "").await.unwrap(), ["Spoiler", "ads"], "trimmed, once each");
|
||||
|
||||
// Refused here, before TypeSafe is asked anything.
|
||||
for bad in ["has a space", "tab\there", &"k".repeat(501)] {
|
||||
assert_eq!(patch(serde_json::json!({ "typesafe_key": bad })).await.0, 400, "{bad}");
|
||||
}
|
||||
assert!(!ctx.db.typesafe_key_set(me.id).await.unwrap());
|
||||
assert_eq!(patch(serde_json::json!({ "typesafe_key": null })).await.0, 204, "forgetting needs no check");
|
||||
assert_eq!(patch(serde_json::json!({ "typesafe_key": " " })).await.0, 204, "nor does an empty one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_picks_are_counted_by_the_hour_and_the_person() {
|
||||
let mut picks = std::collections::HashMap::new();
|
||||
|
||||
Reference in New Issue
Block a user