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:
Gabe Kangas
2025-09-15 13:37:39 -07:00
committed by GitHub
co-authored by Copilot Owncast
parent 74bbe9666f
commit 8eb8bdc18b
5 changed files with 335 additions and 10 deletions
+12
View File
@@ -33,6 +33,18 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
// Names have a max length
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 {
normalizedName := strings.TrimSpace(blockedName)
normalizedName = strings.ToLower(normalizedName)
+39
View File
@@ -30,3 +30,42 @@ func TestSafeString(t *testing.T) {
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 WebsocketService from '../../../services/websocket-service';
import { websocketServiceAtom, currentUserAtom } from '../../stores/ClientConfigStore';
import { validateDisplayName } from '../../../utils/displayNameValidation';
import styles from './NameChangeModal.module.scss';
const { Option } = Select;
@@ -29,7 +30,7 @@ type NameChangeModalProps = {
export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
const currentUser = useRecoilValue(currentUserAtom);
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
const [newName, setNewName] = useState<string>(currentUser?.displayName);
const [newName, setNewName] = useState<string>(currentUser?.displayName || '');
const characterLimit = 30;
@@ -40,21 +41,22 @@ export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
const { displayName, displayColor } = currentUser;
const saveEnabled = () => {
const count = newName !== undefined ? Array.from(newName).length : 0;
return (
newName !== displayName &&
count > 0 &&
count <= characterLimit &&
websocketService?.isConnected()
);
if (!newName || !displayName) return false;
const validation = validateDisplayName(newName, displayName, characterLimit);
return validation.isValid && websocketService?.isConnected();
};
const handleNameChange = () => {
if (!saveEnabled()) return;
if (!newName || !displayName) return;
const validation = validateDisplayName(newName, displayName, characterLimit);
if (!validation.isValid || !websocketService?.isConnected()) {
return;
}
const nameChange = {
type: MessageType.NAME_CHANGE,
newName,
newName: validation.trimmedName,
};
websocketService.send(nameChange);
closeModal();
@@ -73,6 +75,8 @@ export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
const maxColor = 8; // 0...n
const colorOptions = [...Array(maxColor)].map((_, i) => i);
const validation = validateDisplayName(newName, displayName, characterLimit);
const saveButton = (
<Button
type="primary"
@@ -101,6 +105,9 @@ export const NameChangeModal: FC<NameChangeModalProps> = ({ closeModal }) => {
defaultValue={displayName}
className={styles.inputGroup}
/>
{!validation.isValid && validation.errorMessage && (
<div className={styles.error}>{validation.errorMessage}</div>
)}
</Form>
<Form.Item label="Your Color" className={styles.colorChange}>
<Select
+190
View File
@@ -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));
}
});
});
+77
View File
@@ -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,
};
}