Stop using empty/whitespace as chat display names (#4553)
* fix(chat): fixes #4522 to stop people from setting invalid display names * fix(chat): also guard against non-ascii whitespace like non breaking space * Update web/utils/displayNameValidation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: handle additional whitespace * Update web/utils/displayNameValidation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Javascript formatting autofixes * fix: deduplicate running of validation * fix: fix error with useMemo * Update web/utils/displayNameValidation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update web/utils/displayNameValidation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Javascript formatting autofixes * fix: fix component rendering issue --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Owncast <owncast@owncast.online>
This commit is contained in:
co-authored by
Copilot
Owncast
parent
74bbe9666f
commit
8eb8bdc18b
@@ -33,6 +33,18 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
|
|||||||
// Names have a max length
|
// Names have a max length
|
||||||
proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength)
|
proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength)
|
||||||
|
|
||||||
|
// Check if the sanitized name is empty or just whitespace
|
||||||
|
if strings.TrimSpace(proposedUsername) == "" {
|
||||||
|
log.Debugln(logSanitize(eventData.client.User.DisplayName), "attempted to change name to empty or whitespace-only name")
|
||||||
|
message := "Display name cannot be empty or contain only whitespace."
|
||||||
|
s.sendActionToClient(eventData.client, message)
|
||||||
|
|
||||||
|
// Resend the client's user so their username is in sync.
|
||||||
|
eventData.client.sendConnectedClientInfo()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, blockedName := range blocklist {
|
for _, blockedName := range blocklist {
|
||||||
normalizedName := strings.TrimSpace(blockedName)
|
normalizedName := strings.TrimSpace(blockedName)
|
||||||
normalizedName = strings.ToLower(normalizedName)
|
normalizedName = strings.ToLower(normalizedName)
|
||||||
|
|||||||
@@ -30,3 +30,42 @@ func TestSafeString(t *testing.T) {
|
|||||||
t.Errorf("Expected %s, got %s", expectedResult, result)
|
t.Errorf("Expected %s, got %s", expectedResult, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSafeStringWhitespace tests the MakeSafeStringOfLength function with whitespace-only input.
|
||||||
|
func TestSafeStringWhitespace(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: " ",
|
||||||
|
expected: "",
|
||||||
|
name: "spaces only",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "\t\n\r",
|
||||||
|
expected: "",
|
||||||
|
name: "tab, newline, carriage return",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: " \t \n \r ",
|
||||||
|
expected: "",
|
||||||
|
name: "mixed whitespace",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
name: "empty string",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := MakeSafeStringOfLength(tc.input, 30)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("Expected '%s', got '%s'", tc.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Input, Button, Select, Form } from 'antd';
|
|||||||
import { MessageType } from '../../../interfaces/socket-events';
|
import { MessageType } from '../../../interfaces/socket-events';
|
||||||
import WebsocketService from '../../../services/websocket-service';
|
import WebsocketService from '../../../services/websocket-service';
|
||||||
import { websocketServiceAtom, currentUserAtom } from '../../stores/ClientConfigStore';
|
import { websocketServiceAtom, currentUserAtom } from '../../stores/ClientConfigStore';
|
||||||
|
import { validateDisplayName } from '../../../utils/displayNameValidation';
|
||||||
import styles from './NameChangeModal.module.scss';
|
import styles from './NameChangeModal.module.scss';
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
@@ -29,7 +30,7 @@ type NameChangeModalProps = {
|
|||||||
export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
|
export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
|
||||||
const currentUser = useRecoilValue(currentUserAtom);
|
const currentUser = useRecoilValue(currentUserAtom);
|
||||||
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
|
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
|
||||||
const [newName, setNewName] = useState<string>(currentUser?.displayName);
|
const [newName, setNewName] = useState<string>(currentUser?.displayName || '');
|
||||||
|
|
||||||
const characterLimit = 30;
|
const characterLimit = 30;
|
||||||
|
|
||||||
@@ -40,21 +41,22 @@ export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
|
|||||||
const { displayName, displayColor } = currentUser;
|
const { displayName, displayColor } = currentUser;
|
||||||
|
|
||||||
const saveEnabled = () => {
|
const saveEnabled = () => {
|
||||||
const count = newName !== undefined ? Array.from(newName).length : 0;
|
if (!newName || !displayName) return false;
|
||||||
return (
|
const validation = validateDisplayName(newName, displayName, characterLimit);
|
||||||
newName !== displayName &&
|
return validation.isValid && websocketService?.isConnected();
|
||||||
count > 0 &&
|
|
||||||
count <= characterLimit &&
|
|
||||||
websocketService?.isConnected()
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNameChange = () => {
|
const handleNameChange = () => {
|
||||||
if (!saveEnabled()) return;
|
if (!newName || !displayName) return;
|
||||||
|
const validation = validateDisplayName(newName, displayName, characterLimit);
|
||||||
|
|
||||||
|
if (!validation.isValid || !websocketService?.isConnected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nameChange = {
|
const nameChange = {
|
||||||
type: MessageType.NAME_CHANGE,
|
type: MessageType.NAME_CHANGE,
|
||||||
newName,
|
newName: validation.trimmedName,
|
||||||
};
|
};
|
||||||
websocketService.send(nameChange);
|
websocketService.send(nameChange);
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -73,6 +75,8 @@ export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
|
|||||||
const maxColor = 8; // 0...n
|
const maxColor = 8; // 0...n
|
||||||
const colorOptions = [...Array(maxColor)].map((_, i) => i);
|
const colorOptions = [...Array(maxColor)].map((_, i) => i);
|
||||||
|
|
||||||
|
const validation = validateDisplayName(newName, displayName, characterLimit);
|
||||||
|
|
||||||
const saveButton = (
|
const saveButton = (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -101,6 +105,9 @@ export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
|
|||||||
defaultValue={displayName}
|
defaultValue={displayName}
|
||||||
className={styles.inputGroup}
|
className={styles.inputGroup}
|
||||||
/>
|
/>
|
||||||
|
{!validation.isValid && validation.errorMessage && (
|
||||||
|
<div className={styles.error}>{validation.errorMessage}</div>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
<Form.Item label="Your Color" className={styles.colorChange}>
|
<Form.Item label="Your Color" className={styles.colorChange}>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { validateDisplayName, trimUnicodeWhitespace } from '../utils/displayNameValidation';
|
||||||
|
|
||||||
|
describe('Display Name Validation', () => {
|
||||||
|
const currentName = 'CurrentUser';
|
||||||
|
const characterLimit = 30;
|
||||||
|
|
||||||
|
describe('Valid names', () => {
|
||||||
|
test('should accept valid name', () => {
|
||||||
|
const result = validateDisplayName('NewUser', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.trimmedName).toBe('NewUser');
|
||||||
|
expect(result.errorMessage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should trim whitespace and accept valid name', () => {
|
||||||
|
const result = validateDisplayName(' NewUser ', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.trimmedName).toBe('NewUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should trim Unicode whitespace and accept valid name', () => {
|
||||||
|
const result = validateDisplayName(
|
||||||
|
'\u00A0\u2000 NewUser \u2001\u00A0',
|
||||||
|
currentName,
|
||||||
|
characterLimit,
|
||||||
|
);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.trimmedName).toBe('NewUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept name with Unicode characters', () => {
|
||||||
|
const result = validateDisplayName('用户名', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.trimmedName).toBe('用户名');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept name at character limit', () => {
|
||||||
|
const longName = 'A'.repeat(characterLimit);
|
||||||
|
const result = validateDisplayName(longName, currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.trimmedName).toBe(longName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Invalid names - whitespace and empty', () => {
|
||||||
|
test('should reject undefined name', () => {
|
||||||
|
const result = validateDisplayName(undefined, currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject empty string', () => {
|
||||||
|
const result = validateDisplayName('', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot be empty or contain only whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject spaces only', () => {
|
||||||
|
const result = validateDisplayName(' ', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot be empty or contain only whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject tabs only', () => {
|
||||||
|
const result = validateDisplayName('\t\t\t', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot be empty or contain only whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject newlines only', () => {
|
||||||
|
const result = validateDisplayName('\n\n', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot be empty or contain only whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject mixed whitespace', () => {
|
||||||
|
const result = validateDisplayName(' \t\n\r ', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot be empty or contain only whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject Unicode whitespace', () => {
|
||||||
|
const result = validateDisplayName('\u00A0\u2000\u2001\u2002', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot be empty or contain only whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject mixed ASCII and Unicode whitespace', () => {
|
||||||
|
const result = validateDisplayName(' \t\u00A0\u2000\n\u2001 ', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot be empty or contain only whitespace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject zero-width spaces and invisible characters', () => {
|
||||||
|
const result = validateDisplayName('\u200B\u200C\u200D\uFEFF', currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot be empty or contain only whitespace');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Invalid names - other reasons', () => {
|
||||||
|
test('should reject name same as current', () => {
|
||||||
|
const result = validateDisplayName(currentName, currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('New name must be different from current name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject name same as current after trimming', () => {
|
||||||
|
const result = validateDisplayName(` ${currentName} `, currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('New name must be different from current name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject name exceeding character limit', () => {
|
||||||
|
const longName = 'A'.repeat(characterLimit + 1);
|
||||||
|
const result = validateDisplayName(longName, currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe(`Display name cannot exceed ${characterLimit} characters`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle Unicode characters correctly for length', () => {
|
||||||
|
// Emoji characters count as 2 code units but should be treated as 1 character
|
||||||
|
const emojiName = '😀'.repeat(characterLimit + 1);
|
||||||
|
const result = validateDisplayName(emojiName, currentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe(`Display name cannot exceed ${characterLimit} characters`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
test('should handle zero character limit', () => {
|
||||||
|
const result = validateDisplayName('A', currentName, 0);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errorMessage).toBe('Display name cannot exceed 0 characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle very long current name', () => {
|
||||||
|
const veryLongCurrentName = 'A'.repeat(100);
|
||||||
|
const result = validateDisplayName('NewUser', veryLongCurrentName, characterLimit);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.trimmedName).toBe('NewUser');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle special characters', () => {
|
||||||
|
const specialName = '!@#$%^&*()_+{}|:"<>?[]\\;\',./-=`~';
|
||||||
|
const result = validateDisplayName(specialName, currentName, 50);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.trimmedName).toBe(specialName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Display Name Validation - Real-world test cases', () => {
|
||||||
|
const currentName = 'TestUser';
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{ input: 'John', expected: true, description: 'Simple name' },
|
||||||
|
{ input: 'John Doe', expected: true, description: 'Name with space' },
|
||||||
|
{ input: ' John ', expected: true, description: 'Name with padding spaces' },
|
||||||
|
{
|
||||||
|
input: '\u00A0\u2000 John \u2001\u00A0',
|
||||||
|
expected: true,
|
||||||
|
description: 'Name with Unicode whitespace padding',
|
||||||
|
},
|
||||||
|
{ input: '', expected: false, description: 'Empty string' },
|
||||||
|
{ input: ' ', expected: false, description: 'Only spaces' },
|
||||||
|
{ input: '\t', expected: false, description: 'Only tab' },
|
||||||
|
{ input: '\n', expected: false, description: 'Only newline' },
|
||||||
|
{ input: '\u00A0\u2000\u2001', expected: false, description: 'Only Unicode whitespace' },
|
||||||
|
{ input: 'TestUser', expected: false, description: 'Same as current' },
|
||||||
|
{ input: ' TestUser ', expected: false, description: 'Same as current with spaces' },
|
||||||
|
{
|
||||||
|
input: '\u00A0 TestUser \u2000',
|
||||||
|
expected: false,
|
||||||
|
description: 'Same as current with Unicode whitespace',
|
||||||
|
},
|
||||||
|
{ input: 'A'.repeat(31), expected: false, description: 'Too long (31 chars)' },
|
||||||
|
{ input: 'A'.repeat(30), expected: true, description: 'At limit (30 chars)' },
|
||||||
|
{ input: '用户', expected: true, description: 'Chinese characters' },
|
||||||
|
{ input: '😀🎉', expected: true, description: 'Emoji characters' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(testCases)('$description', ({ input, expected }) => {
|
||||||
|
const result = validateDisplayName(input, currentName, 30);
|
||||||
|
expect(result.isValid).toBe(expected);
|
||||||
|
if (expected && result.isValid) {
|
||||||
|
expect(result.trimmedName).toBe(trimUnicodeWhitespace(input));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Validates a display name for chat usage.
|
||||||
|
* @param name The proposed display name
|
||||||
|
* @param currentName The user's current display name (to check if it's different)
|
||||||
|
* @param characterLimit Maximum allowed character count
|
||||||
|
* @returns Object with validation result and error message if invalid
|
||||||
|
*/
|
||||||
|
export interface DisplayNameValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
trimmedName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims Unicode whitespace characters, similar to Go's strings.TrimSpace()
|
||||||
|
* This includes ASCII whitespace plus Unicode space characters and invisible characters
|
||||||
|
*/
|
||||||
|
// Unicode whitespace character class used for trimming (matches Go's strings.TrimSpace)
|
||||||
|
const UNICODE_WHITESPACE_CLASS =
|
||||||
|
'[\\s\\u00A0\\u1680\\u180E\\u2000-\\u200A\\u200B-\\u200D\\u2028\\u2029\\u202F\\u205F\\u3000\\uFEFF]';
|
||||||
|
|
||||||
|
export function trimUnicodeWhitespace(str: string): string {
|
||||||
|
// Unicode whitespace regex that matches what Go's strings.TrimSpace() removes
|
||||||
|
// This pattern matches all relevant Unicode whitespace at start/end of string
|
||||||
|
const unicodeWhitespacePattern = new RegExp(
|
||||||
|
`^${UNICODE_WHITESPACE_CLASS}+|${UNICODE_WHITESPACE_CLASS}+$`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
return str.replace(unicodeWhitespacePattern, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDisplayName(
|
||||||
|
name: string | undefined,
|
||||||
|
currentName: string,
|
||||||
|
characterLimit: number = 30,
|
||||||
|
): DisplayNameValidationResult {
|
||||||
|
// Check if name is provided
|
||||||
|
if (name === undefined) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
errorMessage: 'Display name is required',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim Unicode whitespace (similar to Go's strings.TrimSpace)
|
||||||
|
const trimmedName = trimUnicodeWhitespace(name);
|
||||||
|
|
||||||
|
// Check if trimmed name is empty (was only whitespace or originally empty)
|
||||||
|
if (trimmedName.length === 0) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
errorMessage: 'Display name cannot be empty or contain only whitespace',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if name is different from current
|
||||||
|
if (trimmedName === currentName) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
errorMessage: 'New name must be different from current name',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check character limit (using Unicode-aware length)
|
||||||
|
const characterCount = Array.from(trimmedName).length;
|
||||||
|
if (characterCount > characterLimit) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
errorMessage: `Display name cannot exceed ${characterLimit} characters`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
trimmedName,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user