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>,
payload: UpdateProfilePayload,
) -> Result<SessionResponse, ApiError> {
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<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> {
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("<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(
+4
View File
@@ -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);
+6 -1
View File
@@ -31,11 +31,12 @@
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
window.CrankAuth.handleUnauthorized();
}
var looksLikeHtml = /^\s*</.test(text || '');
var message = payload && payload.error
? payload.error.message
: payload && payload.message
? payload.message
: text
: text && !looksLikeHtml
? text
: ('HTTP ' + response.status);
var error = new Error(message);
@@ -44,6 +45,10 @@
throw error;
}
if (text && payload === null) {
throw new Error('Backend returned a non-JSON response');
}
return payload;
}
+21 -7
View File
@@ -79,7 +79,7 @@
}
function initials(displayName, email) {
var source = displayName || email || 'Crank';
var source = safeIdentityText(displayName, 80, '') || safeIdentityText(email, 254, '') || 'Crank';
return source
.split(/\s+/)
.filter(Boolean)
@@ -88,12 +88,26 @@
.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) {
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');
+22 -6
View File
@@ -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);