diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 4fb0a2b..d08cc33 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1015,19 +1015,12 @@ impl AdminService { current_workspace_id: Option<&WorkspaceId>, payload: UpdateProfilePayload, ) -> Result { - let display_name = payload.display_name.trim(); - let email = payload.email.trim().to_ascii_lowercase(); - - if display_name.is_empty() { - return Err(ApiError::validation("display name is required")); - } - if email.is_empty() || !email.contains('@') { - return Err(ApiError::validation("a valid email address is required")); - } + let display_name = validate_profile_display_name(&payload.display_name)?; + let email = validate_profile_email(&payload.email)?; let user = self .registry - .update_user_profile(user_id, &email, display_name) + .update_user_profile(user_id, &email, &display_name) .await?; let memberships = self.registry.list_workspaces_for_user(user_id).await?; @@ -3717,6 +3710,41 @@ fn normalize_base_url(value: &str) -> String { value.trim().trim_end_matches('/').to_owned() } +fn validate_profile_display_name(value: &str) -> Result { + let display_name = value.trim(); + if display_name.is_empty() { + return Err(ApiError::validation("display name is required")); + } + if display_name.chars().count() > 80 { + return Err(ApiError::validation( + "display name must be at most 80 characters", + )); + } + if display_name + .chars() + .any(|character| character.is_control() || matches!(character, '<' | '>')) + { + return Err(ApiError::validation( + "display name contains unsupported characters", + )); + } + Ok(display_name.to_owned()) +} + +fn validate_profile_email(value: &str) -> Result { + let email = value.trim().to_ascii_lowercase(); + if email.is_empty() + || email.len() > 254 + || !email.contains('@') + || email.chars().any(|character| { + character.is_whitespace() || character.is_control() || matches!(character, '<' | '>') + }) + { + return Err(ApiError::validation("a valid email address is required")); + } + Ok(email) +} + fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> { if payload.name.trim().is_empty() { return Err(ApiError::validation("secret name must not be empty")); @@ -4291,7 +4319,9 @@ mod tests { use crank_core::{ExecutionConfig, HttpMethod, ResponseCachePolicy, RestTarget, Target}; - use super::validate_response_cache_policy; + use super::{ + validate_profile_display_name, validate_profile_email, validate_response_cache_policy, + }; fn cacheable_execution_config() -> ExecutionConfig { ExecutionConfig { @@ -4339,6 +4369,26 @@ mod tests { "response cache is supported only for REST GET operations" ); } + + #[test] + fn validates_profile_identity_fields() { + assert_eq!( + validate_profile_display_name(" Updated Owner ").unwrap(), + "Updated Owner" + ); + assert_eq!( + validate_profile_email(" OWNER@CRANK.LOCAL ").unwrap(), + "owner@crank.local" + ); + } + + #[test] + fn rejects_profile_identity_page_text() { + assert!(validate_profile_display_name("Crank").is_err()); + assert!(validate_profile_display_name("Crank\nOperations\nSave profile").is_err()); + assert!(validate_profile_display_name(&"x".repeat(81)).is_err()); + assert!(validate_profile_email("owner @crank.local").is_err()); + } } fn enrich_operation_summary( diff --git a/apps/ui/css/layout.css b/apps/ui/css/layout.css index b1b2870..a4dc565 100644 --- a/apps/ui/css/layout.css +++ b/apps/ui/css/layout.css @@ -114,6 +114,10 @@ overflow: hidden; z-index: 200; } +.user-dropdown[hidden], +.mobile-nav[hidden] { + display: none !important; +} .user-dropdown-header { padding: 12px 14px; border-bottom: 1px solid var(--border-subtle); diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index f445d47..a0ff2ac 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -31,11 +31,12 @@ if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') { window.CrankAuth.handleUnauthorized(); } + var looksLikeHtml = /^\s* maxLength) { + return fallback || ''; + } + return text; + } + function persistUserMirror(session) { var membership = primaryMembership(session); var user = { id: session.user.id, - name: session.user.display_name, - email: session.user.email, + name: safeIdentityText(session.user.display_name, 80, 'Crank'), + email: safeIdentityText(session.user.email, 254, ''), role: membership ? roleLabel(membership.role) : tKey('workspace_setup.role.viewer'), workspace: membership ? membership.workspace.slug : '', workspaceId: membership ? membership.workspace.id : '', @@ -117,11 +131,11 @@ var membership = primaryMembership(session); var fallbackUser = mirroredUser(); var displayName = session && session.user - ? (session.user.display_name || session.user.email || 'Crank') - : (fallbackUser && fallbackUser.name) || 'Crank'; + ? (safeIdentityText(session.user.display_name, 80, '') || safeIdentityText(session.user.email, 254, '') || 'Crank') + : safeIdentityText(fallbackUser && fallbackUser.name, 80, 'Crank'); var email = session && session.user - ? session.user.email - : fallbackUser && fallbackUser.email; + ? safeIdentityText(session.user.email, 254, '') + : safeIdentityText(fallbackUser && fallbackUser.email, 254, ''); var role = membership ? roleLabel(membership.role) : (fallbackUser && fallbackUser.role) || tKey('workspace_setup.role.viewer'); diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js index edb292f..3dd6345 100644 --- a/apps/ui/js/settings.js +++ b/apps/ui/js/settings.js @@ -13,7 +13,7 @@ function settingsWorkspaceId() { } function initials(displayName, email) { - var source = displayName || email || 'Crank'; + var source = safeIdentityText(displayName, 80, '') || safeIdentityText(email, 254, '') || 'Crank'; return source .split(/\s+/) .filter(Boolean) @@ -22,6 +22,20 @@ function initials(displayName, email) { .join('') || 'CR'; } +function safeIdentityText(value, maxLength, fallback) { + var text = String(value || '') + .replace(/[\r\n\t]+/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim(); + if (!text || /<\s*(?:!doctype|html|head|body|nav|script)\b/i.test(text)) { + return fallback || ''; + } + if (text.length > maxLength) { + return fallback || ''; + } + return text; +} + function setStatus(id, text, isError) { var node = document.getElementById(id); if (!node) return; @@ -118,17 +132,19 @@ function populateProfile(session) { } var user = session.user; - document.getElementById('profile-avatar').textContent = initials(user.display_name, user.email); - document.getElementById('profile-display-name').textContent = user.display_name || 'Crank'; - document.getElementById('profile-email').textContent = user.email || ''; + var displayName = safeIdentityText(user.display_name, 80, 'Crank'); + var userEmail = safeIdentityText(user.email, 254, ''); + document.getElementById('profile-avatar').textContent = initials(displayName, userEmail); + document.getElementById('profile-display-name').textContent = displayName || 'Crank'; + document.getElementById('profile-email').textContent = userEmail; var firstName = document.getElementById('field-firstname'); var email = document.getElementById('field-email'); if (firstName) { - firstName.value = user.display_name || ''; + firstName.value = displayName === 'Crank' ? '' : displayName; } if (email) { - email.value = user.email || ''; + email.value = userEmail; } populateCurrentSession(session);