Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions frontend/src/__tests__/apikeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,33 @@ describe('API Keys Module', () => {
expect(api.revokeApiKey).toHaveBeenCalledWith('key-1');
});

// Regression for ARCH-07 (issue #1195): the previous local escapeHtml
// copy (div.textContent -> div.innerHTML round-trip) did not escape
// quote characters, so a key id containing a double quote interpolated
// into data-key-id="..." broke out of the attribute and injected a new
// attribute (here onmouseover) into the button markup.
test('issue #1195: double quote in key id cannot inject attributes', async () => {
const hostileId = 'key-1" onmouseover="alert(1)';
const mockKeys = [
{
id: hostileId,
name: 'Hostile Key',
key_prefix: 'abc123',
is_active: true,
created_at: '2024-01-15T10:00:00Z'
}
];

(api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: mockKeys });

await loadApiKeys();

const deleteBtn = document.querySelector('.delete-key-btn');
expect(deleteBtn).not.toBeNull();
expect(deleteBtn?.getAttribute('onmouseover')).toBeNull();
expect(deleteBtn?.getAttribute('data-key-id')).toBe(hostileId);
});

test('adds event listener to delete buttons', async () => {
const mockKeys = [
{
Expand Down
30 changes: 24 additions & 6 deletions frontend/src/__tests__/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('users/utils', () => {
describe('escapeHtml', () => {
it('should escape HTML special characters', () => {
expect(userUtils.escapeHtml('<script>alert("xss")</script>')).toBe(
'&lt;script&gt;alert("xss")&lt;/script&gt;'
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
);
});

Expand All @@ -134,14 +134,28 @@ describe('users/utils', () => {
expect(userUtils.escapeHtml('Tom & Jerry')).toBe('Tom &amp; Jerry');
});

// Regression for ARCH-07 (issue #1195): the previous local DOM
// round-trip implementation left both quote characters unescaped,
// so values interpolated inside double-quoted attributes (e.g. the
// aria-label carrying the user email in userList.ts) could break
// out of the attribute and inject markup.
it('should escape single quotes', () => {
const result = userUtils.escapeHtml("It's a test");
expect(result).toBe("It's a test");
expect(result).toBe('It&#39;s a test');
});

it('should escape double quotes', () => {
const result = userUtils.escapeHtml('Say "Hello"');
expect(result).toBe('Say "Hello"');
expect(result).toBe('Say &quot;Hello&quot;');
});

it('issue #1195: quote in attribute position cannot break out of the attribute', () => {
const malicious = 'a@b.com" onmouseover="alert(1)';
const div = document.createElement('div');
div.innerHTML = `<button aria-label="${userUtils.escapeHtml(malicious)}">x</button>`;
const btn = div.querySelector('button');
expect(btn?.getAttribute('onmouseover')).toBeNull();
expect(btn?.getAttribute('aria-label')).toBe(malicious);
});

it('should handle empty string', () => {
Expand All @@ -150,7 +164,7 @@ describe('users/utils', () => {

it('should handle multiple special characters', () => {
expect(userUtils.escapeHtml('<div class="test">&</div>')).toBe(
'&lt;div class="test"&gt;&amp;&lt;/div&gt;'
'&lt;div class=&quot;test&quot;&gt;&amp;&lt;/div&gt;'
);
});
});
Expand Down Expand Up @@ -668,11 +682,15 @@ describe('users/filters', () => {
userFilters.updateGroupFilterDropdown();

const select = document.getElementById('user-group-filter') as HTMLSelectElement;
// No element may be injected out of the value attribute.
expect(select.querySelector('img')).toBeNull();
// The raw payload must not appear verbatim inside the rendered HTML.
expect(select.innerHTML).not.toContain('"><img src=x');
// The option's value should be the escaped form.
// With full escaping (issue #1195 made escapeHtml quote-safe), the
// option value round-trips the original id intact instead of being
// truncated at the first double quote.
const opt = select.options[1];
expect(opt?.value).not.toContain('<img');
expect(opt?.value).toBe('"><img src=x onerror=alert(1)>');
});
});
});
Expand Down
11 changes: 1 addition & 10 deletions frontend/src/apikeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import * as api from './api';
import type { APIKeyInfo, CreateAPIKeyResponse } from './types';
import { formatDateTime, formatRelativeTime } from './utils';
import { escapeHtml, formatDateTime, formatRelativeTime } from './utils';
import { confirmDialog } from './confirmDialog';
import { showToast } from './toast';
import { openModal, closeModal } from './modal';
Expand Down Expand Up @@ -389,12 +389,3 @@ function showError(message: string): void {
showToast({ message, kind: 'error' });
}
}

/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
13 changes: 7 additions & 6 deletions frontend/src/users/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
export { formatRelativeTime, formatDate } from '../utils';

/**
* Escape HTML to prevent XSS
* Escape HTML to prevent XSS. Re-exported from the shared utils module:
* the previous local DOM round-trip (div.textContent -> div.innerHTML)
* did not escape quote characters, so values interpolated inside
* double-quoted attributes could break out of the attribute. The shared
* implementation escapes &, <, >, " and ' and is safe in both text and
* attribute contexts.
*/
export function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export { escapeHtml } from '../utils';

import { showToast } from '../toast';

Expand Down
Loading