diff --git a/nora-registry/src/config.rs b/nora-registry/src/config.rs index 215b3a4..1fe840d 100644 --- a/nora-registry/src/config.rs +++ b/nora-registry/src/config.rs @@ -1,12 +1,19 @@ // Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT +use base64::{engine::general_purpose::STANDARD, Engine}; use serde::{Deserialize, Serialize}; use std::env; use std::fs; pub use crate::secrets::SecretsConfig; +/// Encode "user:pass" into a Basic Auth header value, e.g. "Basic dXNlcjpwYXNz". +/// Returns None if input is None. +pub fn basic_auth_header(credentials: &str) -> String { + format!("Basic {}", STANDARD.encode(credentials)) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub server: ServerConfig, @@ -348,6 +355,37 @@ impl Default for RateLimitConfig { } impl Config { + /// Warn if credentials are configured via config.toml (not env vars) + pub fn warn_plaintext_credentials(&self) { + // Docker upstreams + for (i, upstream) in self.docker.upstreams.iter().enumerate() { + if upstream.auth.is_some() && std::env::var("NORA_DOCKER_UPSTREAMS").is_err() { + tracing::warn!( + upstream_index = i, + url = %upstream.url, + "Docker upstream credentials in config.toml are plaintext — consider NORA_DOCKER_UPSTREAMS env var" + ); + } + } + // Maven proxies + for proxy in &self.maven.proxies { + if proxy.auth().is_some() && std::env::var("NORA_MAVEN_PROXIES").is_err() { + tracing::warn!( + url = %proxy.url(), + "Maven proxy credentials in config.toml are plaintext — consider NORA_MAVEN_PROXIES env var" + ); + } + } + // npm + if self.npm.proxy_auth.is_some() && std::env::var("NORA_NPM_PROXY_AUTH").is_err() { + tracing::warn!("npm proxy credentials in config.toml are plaintext — consider NORA_NPM_PROXY_AUTH env var"); + } + // PyPI + if self.pypi.proxy_auth.is_some() && std::env::var("NORA_PYPI_PROXY_AUTH").is_err() { + tracing::warn!("PyPI proxy credentials in config.toml are plaintext — consider NORA_PYPI_PROXY_AUTH env var"); + } + } + /// Load configuration with priority: ENV > config.toml > defaults pub fn load() -> Self { // 1. Start with defaults diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index be4d5ec..0ee3c65 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -289,6 +289,9 @@ async fn run_server(config: Config, storage: Storage) { let storage_path = config.storage.path.clone(); let rate_limit_enabled = config.rate_limit.enabled; + // Warn about plaintext credentials in config.toml + config.warn_plaintext_credentials(); + // Initialize Docker auth with proxy timeout let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout); diff --git a/nora-registry/src/registry/docker.rs b/nora-registry/src/registry/docker.rs index ae531bb..0c4a4da 100644 --- a/nora-registry/src/registry/docker.rs +++ b/nora-registry/src/registry/docker.rs @@ -3,6 +3,7 @@ use crate::activity_log::{ActionType, ActivityEntry}; use crate::audit::AuditEntry; +use crate::config::basic_auth_header; use crate::registry::docker_auth::DockerAuth; use crate::storage::Storage; use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference}; @@ -15,7 +16,6 @@ use axum::{ routing::{delete, get, head, patch, put}, Json, Router, }; -use base64::{engine::general_purpose::STANDARD, Engine}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -748,8 +748,7 @@ async fn fetch_blob_from_upstream( // First try — with basic auth if configured let mut request = client.get(&url).timeout(Duration::from_secs(timeout)); if let Some(credentials) = basic_auth { - let encoded = STANDARD.encode(credentials); - request = request.header("Authorization", format!("Basic {}", encoded)); + request = request.header("Authorization", basic_auth_header(credentials)); } let response = request.send().await.map_err(|_| ())?; @@ -817,8 +816,7 @@ async fn fetch_manifest_from_upstream( .timeout(Duration::from_secs(timeout)) .header("Accept", accept_header); if let Some(credentials) = basic_auth { - let encoded = STANDARD.encode(credentials); - request = request.header("Authorization", format!("Basic {}", encoded)); + request = request.header("Authorization", basic_auth_header(credentials)); } let response = request.send().await.map_err(|e| { tracing::error!(error = %e, url = %url, "Failed to send request to upstream"); diff --git a/nora-registry/src/registry/docker_auth.rs b/nora-registry/src/registry/docker_auth.rs index 0f74087..8a02dab 100644 --- a/nora-registry/src/registry/docker_auth.rs +++ b/nora-registry/src/registry/docker_auth.rs @@ -1,7 +1,7 @@ // Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT -use base64::{engine::general_purpose::STANDARD, Engine}; +use crate::config::basic_auth_header; use parking_lot::RwLock; use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -91,8 +91,7 @@ impl DockerAuth { let mut request = self.client.get(&url); if let Some(credentials) = basic_auth { - let encoded = STANDARD.encode(credentials); - request = request.header("Authorization", format!("Basic {}", encoded)); + request = request.header("Authorization", basic_auth_header(credentials)); tracing::debug!("Using basic auth for token request"); } @@ -123,8 +122,7 @@ impl DockerAuth { // First try — with basic auth if configured, otherwise anonymous let mut request = self.client.get(url); if let Some(credentials) = basic_auth { - let encoded = STANDARD.encode(credentials); - request = request.header("Authorization", format!("Basic {}", encoded)); + request = request.header("Authorization", basic_auth_header(credentials)); } let response = request.send().await.map_err(|_| ())?; diff --git a/nora-registry/src/registry/maven.rs b/nora-registry/src/registry/maven.rs index b9ba926..53b4442 100644 --- a/nora-registry/src/registry/maven.rs +++ b/nora-registry/src/registry/maven.rs @@ -3,6 +3,7 @@ use crate::activity_log::{ActionType, ActivityEntry}; use crate::audit::AuditEntry; +use crate::config::basic_auth_header; use crate::AppState; use axum::{ body::Bytes, @@ -12,7 +13,6 @@ use axum::{ routing::{get, put}, Router, }; -use base64::{engine::general_purpose::STANDARD, Engine}; use std::sync::Arc; use std::time::Duration; @@ -136,8 +136,7 @@ async fn fetch_from_proxy( ) -> Result, ()> { let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs)); if let Some(credentials) = auth { - let encoded = STANDARD.encode(credentials); - request = request.header("Authorization", format!("Basic {}", encoded)); + request = request.header("Authorization", basic_auth_header(credentials)); } let response = request.send().await.map_err(|_| ())?; diff --git a/nora-registry/src/registry/npm.rs b/nora-registry/src/registry/npm.rs index 676f7ba..fd7699f 100644 --- a/nora-registry/src/registry/npm.rs +++ b/nora-registry/src/registry/npm.rs @@ -3,6 +3,7 @@ use crate::activity_log::{ActionType, ActivityEntry}; use crate::audit::AuditEntry; +use crate::config::basic_auth_header; use crate::AppState; use axum::{ body::Bytes, @@ -12,7 +13,6 @@ use axum::{ routing::get, Router, }; -use base64::{engine::general_purpose::STANDARD, Engine}; use std::sync::Arc; use std::time::Duration; @@ -108,8 +108,7 @@ async fn fetch_from_proxy( ) -> Result, ()> { let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs)); if let Some(credentials) = auth { - let encoded = STANDARD.encode(credentials); - request = request.header("Authorization", format!("Basic {}", encoded)); + request = request.header("Authorization", basic_auth_header(credentials)); } let response = request.send().await.map_err(|_| ())?; diff --git a/nora-registry/src/registry/pypi.rs b/nora-registry/src/registry/pypi.rs index fc23c0e..f1978ee 100644 --- a/nora-registry/src/registry/pypi.rs +++ b/nora-registry/src/registry/pypi.rs @@ -3,6 +3,7 @@ use crate::activity_log::{ActionType, ActivityEntry}; use crate::audit::AuditEntry; +use crate::config::basic_auth_header; use crate::AppState; use axum::{ extract::{Path, State}, @@ -11,7 +12,6 @@ use axum::{ routing::get, Router, }; -use base64::{engine::general_purpose::STANDARD, Engine}; use std::sync::Arc; use std::time::Duration; @@ -217,8 +217,7 @@ async fn fetch_package_page( .timeout(Duration::from_secs(timeout_secs)) .header("Accept", "text/html"); if let Some(credentials) = auth { - let encoded = STANDARD.encode(credentials); - request = request.header("Authorization", format!("Basic {}", encoded)); + request = request.header("Authorization", basic_auth_header(credentials)); } let response = request.send().await.map_err(|_| ())?; @@ -238,8 +237,7 @@ async fn fetch_file( ) -> Result, ()> { let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs)); if let Some(credentials) = auth { - let encoded = STANDARD.encode(credentials); - request = request.header("Authorization", format!("Basic {}", encoded)); + request = request.header("Authorization", basic_auth_header(credentials)); } let response = request.send().await.map_err(|_| ())?;