Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions crates/defguard_core/src/cert_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,37 @@ use utoipa::ToSchema;

use crate::error::WebError;

/// Ensures cert & key pair are valid to avoid bricking the web server after restart.
async fn validate_uploaded_cert_pair(cert_pem: &str, key_pem: &str) -> Result<(), WebError> {
/// Parses an uploaded certificate, validates its key pair, and rejects invalid validity windows.
async fn parse_cert(cert_pem: &str, key_pem: &str) -> Result<CertificateInfo, WebError> {
let _ = rustls::crypto::ring::default_provider().install_default();

RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec())
.await
.map(|_| ())
.map_err(|_| WebError::BadRequest("Invalid certificate or private key PEM".to_string()))
.map_err(|_| WebError::BadRequest("Invalid certificate or private key PEM".to_string()))?;

let cert_der = parse_pem_certificate(cert_pem)?;
let info = CertificateInfo::from_der(cert_der.as_ref())?;

// Validate cert dates
let now = chrono::Utc::now().naive_utc();

if info.not_after <= info.not_before {
return Err(WebError::BadRequest(
"Certificate validity period is invalid".to_string(),
));
}

if info.not_after <= now {
return Err(WebError::BadRequest("Certificate has expired".to_string()));
}

if info.not_before > now {
return Err(WebError::BadRequest(
"Certificate is not valid yet".to_string(),
));
}

Ok(info)
}

/// SSL configuration type for Defguard's internal (core) web server.
Expand Down Expand Up @@ -150,10 +173,7 @@ pub async fn apply_internal_url_settings(
WebError::BadRequest("key_pem is required for own_cert".to_string())
})?;

validate_uploaded_cert_pair(&cert_pem_str, &key_pem_str).await?;

let cert_der = parse_pem_certificate(&cert_pem_str)?;
let info = CertificateInfo::from_der(cert_der.as_ref())?;
let info = parse_cert(&cert_pem_str, &key_pem_str).await?;
let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days();
let expiry = info.not_after;

Expand Down Expand Up @@ -275,10 +295,7 @@ pub async fn apply_external_url_settings(
WebError::BadRequest("key_pem is required for own_cert".to_string())
})?;

validate_uploaded_cert_pair(&cert_pem_str, &key_pem_str).await?;

let cert_der = parse_pem_certificate(&cert_pem_str)?;
let info = CertificateInfo::from_der(cert_der.as_ref())?;
let info = parse_cert(&cert_pem_str, &key_pem_str).await?;
let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days();
let expiry = info.not_after;

Expand Down
12 changes: 12 additions & 0 deletions crates/defguard_core/tests/integration/api/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,18 @@ pub(crate) fn generate_test_cert_pem(common_name: &str) -> (String, String) {
(cert_pem, key_pem)
}

pub(crate) fn generate_expired_test_cert_pem(common_name: &str) -> (String, String) {
let ca = CertificateAuthority::new("Test CA", "test@example.com", 365).unwrap();
let key_pair = generate_key_pair().unwrap();
let san = vec![common_name.to_string()];
let dn = vec![(DnType::CommonName, common_name)];
let csr = Csr::new(&key_pair, &san, dn).unwrap();
let cert = ca.sign_csr_with_validity(&csr, 0).unwrap();
let cert_pem = der_to_pem(cert.der(), PemLabel::Certificate).unwrap();
let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey).unwrap();
(cert_pem, key_pem)
}

/// Set minimal SMTP fields on a [`Settings`] so that `smtp_configured()` returns `true`.
pub(crate) fn configure_smtp(settings: &mut Settings) {
settings.smtp_server = Some("smtp.example.com".into());
Expand Down
18 changes: 17 additions & 1 deletion crates/defguard_core/tests/integration/api/core_certs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use reqwest::StatusCode;
use serde_json::json;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};

use super::common::{generate_test_cert_pem, make_test_client, setup_pool};
use super::common::{
generate_expired_test_cert_pem, generate_test_cert_pem, make_test_client, setup_pool,
};

async fn seed_ca(pool: &sqlx::PgPool) {
let ca = CertificateAuthority::new("Test CA", "test@example.com", 365).unwrap();
Expand Down Expand Up @@ -117,4 +119,18 @@ async fn test_internal_url_settings_endpoint(_: PgPoolOptions, options: PgConnec
.send()
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);

let (expired_cert_pem, expired_key_pem) = generate_expired_test_cert_pem("expired.example.com");
let response = client
.post("/api/v1/core/cert/internal_url_settings")
.json(&json!({
"ssl_type": "own_cert",
"cert_pem": expired_cert_pem,
"key_pem": expired_key_pem
}))
.send()
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = response.json().await;
assert_eq!(body["msg"], "Certificate has expired");
}
17 changes: 16 additions & 1 deletion crates/defguard_core/tests/integration/api/proxy_certs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ use tokio::{
},
};

use super::common::{client::TestClient, generate_test_cert_pem};
use super::common::{client::TestClient, generate_expired_test_cert_pem, generate_test_cert_pem};
use crate::common::{init_config, initialize_users};

// Mock: captures messages sent to the proxy manager channel.
Expand Down Expand Up @@ -324,4 +324,19 @@ async fn test_external_url_settings_endpoint(_: PgPoolOptions, opts: PgConnectOp
.send()
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);

let (expired_cert_pem, expired_key_pem) =
generate_expired_test_cert_pem("expired-edge.example.com");
let response = client
.post("/api/v1/proxy/cert/external_url_settings")
.json(&json!({
"ssl_type": "own_cert",
"cert_pem": expired_cert_pem,
"key_pem": expired_key_pem
}))
.send()
.await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = response.json().await;
assert_eq!(body["msg"], "Certificate has expired");
}
6 changes: 3 additions & 3 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
"@prettier/plugin-oxc": "^0.0.4",
"@types/node": "^22.19.17",
"@types/totp-generator": "^0.0.8",
"@typescript-eslint/eslint-plugin": "^8.58.1",
"@typescript-eslint/parser": "^8.58.1",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
Expand All @@ -33,7 +33,7 @@
"@types/lodash": "^4.17.24",
"@types/pg": "^8.20.0",
"axios": "^1.15.0",
"dotenv": "^17.4.1",
"dotenv": "^17.4.2",
"lodash": "^4.18.1",
"pg": "^8.20.0",
"playwright": "^1.59.1",
Expand Down
Loading
Loading