Harden community profile identity rendering
Deploy / deploy (push) Successful in 2m9s
CI / Rust Checks (push) Successful in 5m59s
CI / UI Checks (push) Successful in 6s
CI / Deployment Manifests (push) Successful in 3s
CI / Frontend E2E (push) Successful in 4m24s

This commit is contained in:
github-ops
2026-06-19 19:06:48 +00:00
parent db97d37868
commit cbfa412ce0
5 changed files with 114 additions and 25 deletions
+61 -11
View File
@@ -1015,19 +1015,12 @@ impl AdminService {
current_workspace_id: Option<&WorkspaceId>, current_workspace_id: Option<&WorkspaceId>,
payload: UpdateProfilePayload, payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> { ) -> Result<SessionResponse, ApiError> {
let display_name = payload.display_name.trim(); let display_name = validate_profile_display_name(&payload.display_name)?;
let email = payload.email.trim().to_ascii_lowercase(); let email = validate_profile_email(&payload.email)?;
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 user = self let user = self
.registry .registry
.update_user_profile(user_id, &email, display_name) .update_user_profile(user_id, &email, &display_name)
.await?; .await?;
let memberships = self.registry.list_workspaces_for_user(user_id).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() value.trim().trim_end_matches('/').to_owned()
} }
fn validate_profile_display_name(value: &str) -> Result<String, ApiError> {
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<String, ApiError> {
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> { fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() { if payload.name.trim().is_empty() {
return Err(ApiError::validation("secret name must not be 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 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 { fn cacheable_execution_config() -> ExecutionConfig {
ExecutionConfig { ExecutionConfig {
@@ -4339,6 +4369,26 @@ mod tests {
"response cache is supported only for REST GET operations" "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("<html>Crank</html>").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 <html>@crank.local").is_err());
}
} }
fn enrich_operation_summary( fn enrich_operation_summary(
+4
View File
@@ -114,6 +114,10 @@
overflow: hidden; overflow: hidden;
z-index: 200; z-index: 200;
} }
.user-dropdown[hidden],
.mobile-nav[hidden] {
display: none !important;
}
.user-dropdown-header { .user-dropdown-header {
padding: 12px 14px; padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
+6 -1
View File
@@ -31,11 +31,12 @@
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') { if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
window.CrankAuth.handleUnauthorized(); window.CrankAuth.handleUnauthorized();
} }
var looksLikeHtml = /^\s*</.test(text || '');
var message = payload && payload.error var message = payload && payload.error
? payload.error.message ? payload.error.message
: payload && payload.message : payload && payload.message
? payload.message ? payload.message
: text : text && !looksLikeHtml
? text ? text
: ('HTTP ' + response.status); : ('HTTP ' + response.status);
var error = new Error(message); var error = new Error(message);
@@ -44,6 +45,10 @@
throw error; throw error;
} }
if (text && payload === null) {
throw new Error('Backend returned a non-JSON response');
}
return payload; return payload;
} }
+21 -7
View File
@@ -79,7 +79,7 @@
} }
function initials(displayName, email) { function initials(displayName, email) {
var source = displayName || email || 'Crank'; var source = safeIdentityText(displayName, 80, '') || safeIdentityText(email, 254, '') || 'Crank';
return source return source
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
@@ -88,12 +88,26 @@
.join('') || 'CR'; .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 persistUserMirror(session) { function persistUserMirror(session) {
var membership = primaryMembership(session); var membership = primaryMembership(session);
var user = { var user = {
id: session.user.id, id: session.user.id,
name: session.user.display_name, name: safeIdentityText(session.user.display_name, 80, 'Crank'),
email: session.user.email, email: safeIdentityText(session.user.email, 254, ''),
role: membership ? roleLabel(membership.role) : tKey('workspace_setup.role.viewer'), role: membership ? roleLabel(membership.role) : tKey('workspace_setup.role.viewer'),
workspace: membership ? membership.workspace.slug : '', workspace: membership ? membership.workspace.slug : '',
workspaceId: membership ? membership.workspace.id : '', workspaceId: membership ? membership.workspace.id : '',
@@ -117,11 +131,11 @@
var membership = primaryMembership(session); var membership = primaryMembership(session);
var fallbackUser = mirroredUser(); var fallbackUser = mirroredUser();
var displayName = session && session.user var displayName = session && session.user
? (session.user.display_name || session.user.email || 'Crank') ? (safeIdentityText(session.user.display_name, 80, '') || safeIdentityText(session.user.email, 254, '') || 'Crank')
: (fallbackUser && fallbackUser.name) || 'Crank'; : safeIdentityText(fallbackUser && fallbackUser.name, 80, 'Crank');
var email = session && session.user var email = session && session.user
? session.user.email ? safeIdentityText(session.user.email, 254, '')
: fallbackUser && fallbackUser.email; : safeIdentityText(fallbackUser && fallbackUser.email, 254, '');
var role = membership var role = membership
? roleLabel(membership.role) ? roleLabel(membership.role)
: (fallbackUser && fallbackUser.role) || tKey('workspace_setup.role.viewer'); : (fallbackUser && fallbackUser.role) || tKey('workspace_setup.role.viewer');
+22 -6
View File
@@ -13,7 +13,7 @@ function settingsWorkspaceId() {
} }
function initials(displayName, email) { function initials(displayName, email) {
var source = displayName || email || 'Crank'; var source = safeIdentityText(displayName, 80, '') || safeIdentityText(email, 254, '') || 'Crank';
return source return source
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
@@ -22,6 +22,20 @@ function initials(displayName, email) {
.join('') || 'CR'; .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) { function setStatus(id, text, isError) {
var node = document.getElementById(id); var node = document.getElementById(id);
if (!node) return; if (!node) return;
@@ -118,17 +132,19 @@ function populateProfile(session) {
} }
var user = session.user; var user = session.user;
document.getElementById('profile-avatar').textContent = initials(user.display_name, user.email); var displayName = safeIdentityText(user.display_name, 80, 'Crank');
document.getElementById('profile-display-name').textContent = user.display_name || 'Crank'; var userEmail = safeIdentityText(user.email, 254, '');
document.getElementById('profile-email').textContent = user.email || ''; 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 firstName = document.getElementById('field-firstname');
var email = document.getElementById('field-email'); var email = document.getElementById('field-email');
if (firstName) { if (firstName) {
firstName.value = user.display_name || ''; firstName.value = displayName === 'Crank' ? '' : displayName;
} }
if (email) { if (email) {
email.value = user.email || ''; email.value = userEmail;
} }
populateCurrentSession(session); populateCurrentSession(session);