433 lines
17 KiB
Rust
433 lines
17 KiB
Rust
#[tokio::test]
|
|
async fn csrf_required_and_logout_revokes_cookie() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let req = request("/api/auth/logout")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
let req = request("/api/auth/logout")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", csrf)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status(),
|
|
StatusCode::OK
|
|
);
|
|
let req = request("/api/status")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.oneshot(req).await.unwrap().status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn upload_and_scoped_queries_keep_control_plane_guards() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for (path, body) in [
|
|
(
|
|
"/api/certificates/upload",
|
|
r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#,
|
|
),
|
|
(
|
|
"/api/certificates/deploy",
|
|
r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#,
|
|
),
|
|
] {
|
|
for (authenticated, valid_csrf, expected) in
|
|
[(false, false, 401), (true, false, 403), (true, true, 409)]
|
|
{
|
|
let mut req = request(path)
|
|
.method("POST")
|
|
.header("content-type", "application/json")
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1");
|
|
if authenticated {
|
|
req = req.header("cookie", &cookie);
|
|
}
|
|
if valid_csrf {
|
|
req = req.header("x-csrf-token", &csrf);
|
|
}
|
|
assert_eq!(
|
|
router
|
|
.clone()
|
|
.oneshot(req.body(Body::from(body)).unwrap())
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
.as_u16(),
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
for (path, expected) in [
|
|
("/api/logs?vhost=demo&limit=8", 200),
|
|
("/api/logs?cache=BAD", 400),
|
|
("/api/file-logs", 200),
|
|
("/api/file-logs/query?source=all&limit=8", 200),
|
|
("/api/file-logs/query?limit=0", 400),
|
|
("/api/traffic", 200),
|
|
("/api/cache", 200),
|
|
("/api/transform", 200),
|
|
] {
|
|
let req = request(path)
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status().as_u16(),
|
|
expected
|
|
);
|
|
}
|
|
let req = request("/api/cache/purge?vhost=missing")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.oneshot(req).await.unwrap().status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn certificate_reload_keeps_configuration_revision_stable() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/reload")
|
|
.method("POST")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload = json_body(response).await;
|
|
assert_eq!(payload["reloaded"], true);
|
|
assert_eq!(payload["revision"], 0);
|
|
|
|
let configuration = router
|
|
.oneshot(
|
|
request("/api/config")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(json_body(configuration).await["revision"], 0);
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn manual_certificate_can_be_reloaded_by_id_without_revision_change() {
|
|
let mut config = Config::default();
|
|
config.tls.certificates.push(crate::tls::Certificate {
|
|
issuer: None,
|
|
id: "manual-file".into(),
|
|
covered_hosts: Vec::new(),
|
|
hosts: Vec::new(),
|
|
cert_file: "default.pem".into(),
|
|
key_file: "default.key".into(),
|
|
});
|
|
let (router, store, _dir, secret, csrf) = fixture_with_certificate_storage(config).await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/manual-file/reload")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload = json_body(response).await;
|
|
assert_eq!(payload["certificate_id"], "manual-file");
|
|
assert_eq!(payload["revision"], 0);
|
|
assert_eq!(payload["reloaded"], true);
|
|
|
|
let api_token = "c".repeat(64);
|
|
store
|
|
.upsert_certificate_api_credential(
|
|
"file-renewer",
|
|
&crate::auth::secret_hash(&api_token),
|
|
"external file renewal hook",
|
|
false,
|
|
&["manual-file".to_owned()],
|
|
"owner",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let external = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/manual-file/reload")
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {api_token}"))
|
|
.header("x-rustproxy-certificate-key", "file-renewer")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(external.status(), StatusCode::OK);
|
|
assert_eq!(json_body(external).await["certificate_id"], "manual-file");
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn certificate_api_tokens_are_scoped_commented_and_individually_revocable() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let token = "a".repeat(64);
|
|
let token_two = "b".repeat(64);
|
|
let invalid_key = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(json!({"key_id":"x","token":token,"certificate_ids":["shared-cert"]}).to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(invalid_key.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
for (key_id, value, comment) in [
|
|
("certbot-main", token.as_str(), "production certbot"),
|
|
("deploy-hook", token_two.as_str(), "secondary deploy hook"),
|
|
] {
|
|
let rotated = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"key_id":key_id,
|
|
"token":value,
|
|
"comment":comment,
|
|
"certificate_ids":[if key_id == "certbot-main" { "shared-cert" } else { "deploy-cert" }],
|
|
"allow_deploy": key_id == "deploy-hook"
|
|
}).to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rotated.status(), StatusCode::OK);
|
|
let rotated = json_body(rotated).await;
|
|
assert_eq!(rotated["configured"], true);
|
|
assert_eq!(rotated["credential"]["key_id"], key_id);
|
|
assert_eq!(rotated["credential"]["comment"], comment);
|
|
assert_eq!(rotated["credential"]["allow_deploy"], key_id == "deploy-hook");
|
|
assert_eq!(rotated["credential"]["certificate_ids"].as_array().unwrap().len(), 1);
|
|
assert_eq!(rotated["token"], value);
|
|
}
|
|
|
|
let status = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = json_body(status).await;
|
|
assert_eq!(status["configured"], true);
|
|
assert_eq!(status["credentials"].as_array().unwrap().len(), 2);
|
|
assert!(status.get("token").is_none());
|
|
|
|
let external = |path: &'static str, key_id: &'static str, value: String| {
|
|
request(path)
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {value}"))
|
|
.header("x-rustproxy-certificate-key", key_id)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap()
|
|
};
|
|
// Certificate API credentials cannot trigger an all-certificate reload.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external("/api/certificates/reload", "certbot-main", token.clone()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
// The allowlisted ID passes the auth gate and reaches the handler; this
|
|
// fixture intentionally has no such configured certificate, so it is 404.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
// A reload-only token cannot use the deploy endpoint at all.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/deploy")
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("x-rustproxy-certificate-key", "certbot-main")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
// A different ID is rejected by the credential grant before the handler.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external("/api/certificates/deploy-cert/reload", "certbot-main", token.clone()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/deploy")
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {token_two}"))
|
|
.header("x-rustproxy-certificate-key", "deploy-hook")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({"certificate_id":"shared-cert","cert_pem":"bad","key_pem":"bad"}).to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
|
|
let missing_key = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/reload")
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(missing_key.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let forbidden_scope = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("x-rustproxy-certificate-key", "certbot-main")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let revoked = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api/certbot-main")
|
|
.method("DELETE")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revoked.status(), StatusCode::OK);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
let revoked_all = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api")
|
|
.method("DELETE")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revoked_all.status(), StatusCode::OK);
|
|
let response = router
|
|
.oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
store.close().await;
|
|
}
|