fix: security audit — patch vulnerabilities V-02 through V-16

- V-02: XSS via innerHTML in profile.js — wrap err.message in escapeHtml()
- V-03: IDOR upload to other users' folders — add folder ownership check
- V-04: IDOR create folders in other users' trees — add parent ownership check
- V-06: Content-Disposition header injection — RFC 5987 percent-encoding
- V-08: WebDAV MOVE/COPY destination without ownership — add assert_owner checks
- V-09: .gitignore missing cert/key patterns — add *.pem, *.key, *.p12, etc.
- V-11: Username accepts XSS payloads — restrict to [a-zA-Z0-9._-]
- V-12: Minimal email validation — reject forbidden chars, require domain dot
- V-13: admin_reset_password doesn't invalidate sessions — revoke all sessions
- V-14: Rate limiting bypassable via X-Forwarded-For — gate behind OXICLOUD_TRUST_PROXY_HEADERS
- V-15: Cookie Secure flag off by default — default to true (safe-by-default)
- V-16: LIKE wildcard injection in searches — add like_escape() helper across 9 sites
This commit is contained in:
Dionisio
2026-03-05 14:52:11 +01:00
parent b503e08384
commit 33cfb0faef
14 changed files with 265 additions and 58 deletions
+71 -17
View File
@@ -58,15 +58,8 @@ impl User {
storage_quota_bytes: i64,
) -> UserResult<Self> {
// Validations
if username.is_empty() || username.len() < 3 || username.len() > 32 {
return Err(UserError::InvalidUsername(
"Username must be between 3 and 32 characters".to_string(),
));
}
if !email.contains('@') || email.len() < 5 {
return Err(UserError::ValidationError("Invalid email".to_string()));
}
Self::validate_username(&username)?;
Self::validate_email(&email)?;
if password_hash.is_empty() {
return Err(UserError::InvalidPassword(
@@ -102,14 +95,8 @@ impl User {
oidc_provider: String,
oidc_subject: String,
) -> UserResult<Self> {
if username.is_empty() || username.len() < 3 || username.len() > 32 {
return Err(UserError::InvalidUsername(
"Username must be between 3 and 32 characters".to_string(),
));
}
if !email.contains('@') || email.len() < 5 {
return Err(UserError::ValidationError("Invalid email".to_string()));
}
Self::validate_username(&username)?;
Self::validate_email(&email)?;
let now = Utc::now();
Ok(Self {
id: Uuid::new_v4().to_string(),
@@ -283,4 +270,71 @@ impl User {
self.active = true;
self.updated_at = Utc::now();
}
// ── Shared validation helpers ──────────────────────────────────────
/// Usernames must be 3-32 chars and contain only ASCII alphanumerics,
/// hyphens, underscores, and dots. This prevents XSS payloads like
/// `<img/src=x>` from being stored as usernames.
fn validate_username(username: &str) -> UserResult<()> {
if username.len() < 3 || username.len() > 32 {
return Err(UserError::InvalidUsername(
"Username must be between 3 and 32 characters".to_string(),
));
}
if !username
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(UserError::InvalidUsername(
"Username may only contain letters, digits, hyphens, underscores, and dots"
.to_string(),
));
}
// Disallow leading/trailing dots or hyphens
if username.starts_with('.') || username.starts_with('-')
|| username.ends_with('.') || username.ends_with('-')
{
return Err(UserError::InvalidUsername(
"Username must not start or end with a dot or hyphen".to_string(),
));
}
Ok(())
}
/// Basic but meaningful email validation:
/// - Must contain exactly one `@`
/// - Local part and domain must be non-empty
/// - Domain must contain at least one dot
/// - No angle brackets, spaces, or other characters used in XSS payloads
fn validate_email(email: &str) -> UserResult<()> {
let parts: Vec<&str> = email.splitn(2, '@').collect();
if parts.len() != 2 {
return Err(UserError::ValidationError("Invalid email: missing @".to_string()));
}
let (local, domain) = (parts[0], parts[1]);
if local.is_empty() || domain.is_empty() {
return Err(UserError::ValidationError(
"Invalid email: empty local part or domain".to_string(),
));
}
if !domain.contains('.') {
return Err(UserError::ValidationError(
"Invalid email: domain must contain a dot".to_string(),
));
}
// Reject characters commonly used in XSS / header injection
let forbidden = ['<', '>', '"', '\'', '\\', ' ', '\t', '\n', '\r', '(', ')', ',', ';'];
if email.chars().any(|c| forbidden.contains(&c)) {
return Err(UserError::ValidationError(
"Invalid email: contains forbidden characters".to_string(),
));
}
if email.len() > 254 {
return Err(UserError::ValidationError(
"Invalid email: too long (max 254 characters)".to_string(),
));
}
Ok(())
}
}