From dc02c63058b15927c113dc70e16d0095850e1134 Mon Sep 17 00:00:00 2001
From: Alex Sorafumo
Date: Wed, 30 Sep 2026 11:02:13 +1000
Subject: [PATCH 01/10] fix(ui): escape user names in confirm modal and editor
HTML
Confirm modal content is rendered with innerHTML, so resource names
from the API could inject markup. Add an escapeHtml helper and use it
where callers build HTML strings. Escape uploaded file names before
SunEditor insertHTML, and destroy the editor on re-init and destroy.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/app/admin/build-list.component.ts | 5 +++--
src/app/admin/edge.component.ts | 4 ++--
src/app/admin/extensions.component.ts | 3 ++-
src/app/admin/resource-imports.component.ts | 3 ++-
.../admin/signage-ai/signage-ai.component.ts | 5 +++--
src/app/admin/staff-api.component.ts | 3 ++-
src/app/admin/storage/storage.component.ts | 5 +++--
src/app/admin/upload-library.component.ts | 3 ++-
src/app/common/general.ts | 17 ++++++++++++++
src/app/common/item.service.ts | 5 +++--
src/app/domains/domain-state.service.ts | 3 ++-
src/app/groups/group-state.service.ts | 5 +++--
src/app/systems/system-state.service.ts | 6 ++---
src/app/triggers/trigger-state.service.ts | 3 ++-
.../rich-text-input.component.ts | 22 +++++++++++++------
src/app/ui/metadata-display.component.ts | 3 ++-
src/app/users/users-state.service.ts | 7 ++++--
src/app/zones/zones-state.service.ts | 6 ++---
src/tests/common/general.spec.ts | 14 ++++++++++++
src/tests/users/user-lifecycle.spec.ts | 5 ++++-
20 files changed, 92 insertions(+), 35 deletions(-)
diff --git a/src/app/admin/build-list.component.ts b/src/app/admin/build-list.component.ts
index da1942124..0d311b8cb 100644
--- a/src/app/admin/build-list.component.ts
+++ b/src/app/admin/build-list.component.ts
@@ -5,6 +5,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { del, get } from '@placeos/ts-client';
import { toQueryString } from '../common/api';
+import { escapeHtml } from '../common/general';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
import { openConfirmModal } from '../overlays/confirm-modal.component';
@@ -159,8 +160,8 @@ export class PlaceBuildListComponent implements OnInit {
{
title: i18n('ADMIN.BUILD_LIST_REMOVE'),
content: i18n('ADMIN.BUILD_LIST_REMOVE_MSG', {
- driver: i.driver,
- repo: i.repo,
+ driver: escapeHtml(i.driver),
+ repo: escapeHtml(i.repo),
}),
icon: { type: 'icon', content: 'delete' },
},
diff --git a/src/app/admin/edge.component.ts b/src/app/admin/edge.component.ts
index 404b1f78a..f0dabe5ae 100644
--- a/src/app/admin/edge.component.ts
+++ b/src/app/admin/edge.component.ts
@@ -11,7 +11,7 @@ import {
removeEdge,
retrieveEdgeToken,
} from '@placeos/ts-client';
-import { copyToClipboard } from '../common/general';
+import { copyToClipboard, escapeHtml } from '../common/general';
import {
notifyError,
notifyInfo,
@@ -228,7 +228,7 @@ export class PlaceEdgeComponent implements OnInit {
const details = await openConfirmModal(
{
title: 'Remove edge?',
- content: `Remove ${i.name}?
You or your users may lose access to some data.`,
+ content: `Remove ${escapeHtml(i.name)}?
You or your users may lose access to some data.`,
icon: { type: 'icon', content: 'delete' },
},
this._dialog,
diff --git a/src/app/admin/extensions.component.ts b/src/app/admin/extensions.component.ts
index e4868ef7f..916737364 100644
--- a/src/app/admin/extensions.component.ts
+++ b/src/app/admin/extensions.component.ts
@@ -7,6 +7,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import { PlaceDomain, updateDomain } from '@placeos/ts-client';
+import { escapeHtml } from '../common/general';
import { notifyError } from '../common/notifications';
import { waitForEvent } from '../common/signals';
import { ApplicationIcon, DialogEvent } from '../common/types';
@@ -256,7 +257,7 @@ export class PlaceExtensionsComponent implements OnInit {
{
data: {
title: 'Remove extension',
- content: `Are you sure you want to remove the extension "${item.name}" from ${item.type}?`,
+ content: `Are you sure you want to remove the extension "${escapeHtml(item.name)}" from ${escapeHtml(item.type)}?`,
icon: { content: 'delete' },
},
},
diff --git a/src/app/admin/resource-imports.component.ts b/src/app/admin/resource-imports.component.ts
index 45f7a8010..2c13cff86 100644
--- a/src/app/admin/resource-imports.component.ts
+++ b/src/app/admin/resource-imports.component.ts
@@ -8,6 +8,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { addSystem, query, querySystemsWithEmails } from '@placeos/ts-client';
+import { escapeHtml } from '../common/general';
import { i18n } from '../common/locale.service';
import { notifySuccess, notifyWarn } from '../common/notifications';
import { openConfirmModal } from '../overlays/confirm-modal.component';
@@ -215,7 +216,7 @@ export class ResourceImportsComponent implements OnInit {
count: missing.length,
})}
${missing
- .map((_) => `- ${_.display_name}
`)
+ .map((_) => `- ${escapeHtml(_.display_name)}
`)
.join('')}
`,
icon: { type: 'icon', content: 'publish' },
diff --git a/src/app/admin/signage-ai/signage-ai.component.ts b/src/app/admin/signage-ai/signage-ai.component.ts
index 17e67f6bf..2669df37e 100644
--- a/src/app/admin/signage-ai/signage-ai.component.ts
+++ b/src/app/admin/signage-ai/signage-ai.component.ts
@@ -3,6 +3,7 @@ import { MatRippleModule } from '@angular/material/core';
import { MatDialog } from '@angular/material/dialog';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
+import { escapeHtml } from '../../common/general';
import { i18n } from '../../common/locale.service';
import { notifyError, notifySuccess } from '../../common/notifications';
import { openConfirmModal } from '../../overlays/confirm-modal.component';
@@ -284,7 +285,7 @@ export class SignageAIComponent implements OnInit {
{
title: i18n('ADMIN.AI_PROVIDER_TEST_TITLE'),
content: i18n('ADMIN.AI_PROVIDER_TEST_MSG', {
- name: item.name,
+ name: escapeHtml(item.name),
}),
icon: { content: 'bolt' },
},
@@ -314,7 +315,7 @@ export class SignageAIComponent implements OnInit {
{
title: i18n('ADMIN.AI_PROVIDER_REMOVE_TITLE'),
content: i18n('ADMIN.AI_PROVIDER_REMOVE_MSG', {
- name: item.name,
+ name: escapeHtml(item.name),
}),
icon: { content: 'delete_forever' },
},
diff --git a/src/app/admin/staff-api.component.ts b/src/app/admin/staff-api.component.ts
index 544275828..ea96c8952 100644
--- a/src/app/admin/staff-api.component.ts
+++ b/src/app/admin/staff-api.component.ts
@@ -9,6 +9,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import { del, get, PlaceDomain } from '@placeos/ts-client';
import { addDays, getUnixTime, startOfDay } from 'date-fns';
+import { escapeHtml } from '../common/general';
import { notifyError, notifySuccess } from '../common/notifications';
import { HashMap } from '../common/types';
import { openConfirmModal } from '../overlays/confirm-modal.component';
@@ -247,7 +248,7 @@ export class PlaceStaffAPIComponent implements OnInit {
const details = await openConfirmModal(
{
title: 'Remove tenant?',
- content: `Remove ${tenant.name} from this domain?
+ content: `Remove ${escapeHtml(tenant.name)} from this domain?
This will remove all related:
- bookings (such as desk bookings)
diff --git a/src/app/admin/storage/storage.component.ts b/src/app/admin/storage/storage.component.ts
index 19efc24ec..3ddcdb22f 100644
--- a/src/app/admin/storage/storage.component.ts
+++ b/src/app/admin/storage/storage.component.ts
@@ -6,6 +6,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
+import { escapeHtml } from '../../common/general';
import { i18n } from '../../common/locale.service';
import { openConfirmModal } from '../../overlays/confirm-modal.component';
import { IconComponent } from '../../ui/icon.component';
@@ -220,8 +221,8 @@ export class StorageComponent implements OnInit {
{
title: i18n('ADMIN.STORAGE_REMOVE_TITLE'),
content: i18n('ADMIN.STORAGE_REMOVE_MSG', {
- type: item.storage_type,
- name: item.bucket_name,
+ type: escapeHtml(item.storage_type),
+ name: escapeHtml(item.bucket_name),
}),
icon: { content: 'delete_forever' },
},
diff --git a/src/app/admin/upload-library.component.ts b/src/app/admin/upload-library.component.ts
index 43f6984fe..36cf4bc4a 100644
--- a/src/app/admin/upload-library.component.ts
+++ b/src/app/admin/upload-library.component.ts
@@ -19,6 +19,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import { apiKey, cleanObject, query, remove, token } from '@placeos/ts-client';
import { AsyncHandler } from '../common/async-handler.class';
+import { escapeHtml } from '../common/general';
import { i18n } from '../common/locale.service';
import {
notifyError,
@@ -531,7 +532,7 @@ export class UploadLibraryComponent extends AsyncHandler implements OnInit {
{
title: i18n('ADMIN.UPLOADS_LIB_REMOVE'),
content: i18n('ADMIN.UPLOADS_LIB_REMOVE_MSG', {
- filename: upload.file_name,
+ filename: escapeHtml(upload.file_name),
}),
icon: { type: 'icon', content: 'delete' },
},
diff --git a/src/app/common/general.ts b/src/app/common/general.ts
index dba803ee7..da6330d6a 100644
--- a/src/app/common/general.ts
+++ b/src/app/common/general.ts
@@ -105,6 +105,23 @@ export function unique(array: T[], key = '') {
);
}
+const HTML_ENTITIES: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+};
+
+/**
+ * Escape a value for use in an HTML string, e.g. a user controlled name
+ * inside confirm modal content or editor HTML
+ * @param value Value to escape
+ */
+export function escapeHtml(value: unknown): string {
+ return `${value ?? ''}`.replace(/[&<>"']/g, (char) => HTML_ENTITIES[char]);
+}
+
/**
* Generate a random number
* @param ceil Biggest value to generate not inclusive
diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts
index 2f76f70c3..f03b0c24c 100644
--- a/src/app/common/item.service.ts
+++ b/src/app/common/item.service.ts
@@ -34,7 +34,7 @@ import {
CascadeResource,
runCascade,
} from './cascade-delete';
-import { log } from './general';
+import { escapeHtml, log } from './general';
import { i18n } from './locale.service';
import { notifyError, notifySuccess } from './notifications';
import { waitForEvent, waitForSignalValue } from './signals';
@@ -342,12 +342,13 @@ export class ActiveItemService extends AsyncHandler {
? 'USERS.FORCE_DELETE_MSG'
: `${actions.name}.DELETE_MSG`,
{
- name:
+ name: escapeHtml(
(
item as PlaceResource & {
display_name?: string;
}
).display_name || item.name,
+ ),
},
),
extra: actions.delete_extra
diff --git a/src/app/domains/domain-state.service.ts b/src/app/domains/domain-state.service.ts
index 6da59ddbf..9ab0eebd6 100644
--- a/src/app/domains/domain-state.service.ts
+++ b/src/app/domains/domain-state.service.ts
@@ -31,6 +31,7 @@ import {
updateDomain,
} from '@placeos/ts-client';
import { filter, map } from 'rxjs';
+import { escapeHtml } from '../common/general';
import { ActiveItemService } from '../common/item.service';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
@@ -258,7 +259,7 @@ export class DomainStateService {
const details = await openConfirmModal(
{
title: `Delete application`,
- content: `Are you sure you want delete the application ${item.name}?
Configuration will be immediately updated
`,
+ content: `Are you sure you want delete the application ${escapeHtml(item.name)}?
Configuration will be immediately updated
`,
icon: { type: 'icon', content: 'delete' },
},
this._dialog,
diff --git a/src/app/groups/group-state.service.ts b/src/app/groups/group-state.service.ts
index 4d56f49fc..6b28ca0e5 100644
--- a/src/app/groups/group-state.service.ts
+++ b/src/app/groups/group-state.service.ts
@@ -17,6 +17,7 @@ import {
updateGroupUser,
updateGroupZone,
} from '@placeos/ts-client';
+import { escapeHtml } from '../common/general';
import { ActiveItemService } from '../common/item.service';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
@@ -250,7 +251,7 @@ export class GroupStateService {
{
title: i18n('GROUPS.USER_REMOVE'),
content: i18n('GROUPS.USER_REMOVE_MSG', {
- name: item.user?.name || item.user_id,
+ name: escapeHtml(item.user?.name || item.user_id),
}),
icon: { type: 'icon', content: 'delete' },
},
@@ -312,7 +313,7 @@ export class GroupStateService {
{
title: i18n('GROUPS.ZONE_REMOVE'),
content: i18n('GROUPS.ZONE_REMOVE_MSG', {
- name: item.zone?.name || item.zone_id,
+ name: escapeHtml(item.zone?.name || item.zone_id),
}),
icon: { type: 'icon', content: 'delete' },
},
diff --git a/src/app/systems/system-state.service.ts b/src/app/systems/system-state.service.ts
index 441156b57..e61720c39 100644
--- a/src/app/systems/system-state.service.ts
+++ b/src/app/systems/system-state.service.ts
@@ -31,7 +31,7 @@ import {
import { calculateModuleIndex } from '../common/api';
import { AsyncHandler } from '../common/async-handler.class';
import { PlaceDebugService } from '../common/debug.service';
-import { unique } from '../common/general';
+import { escapeHtml, unique } from '../common/general';
import {
isSubsystemUser,
querySupportSystems as querySystems,
@@ -488,7 +488,7 @@ export class SystemStateService extends AsyncHandler {
public async removeTrigger(trigger: PlaceTrigger) {
const details = await this.confirm({
title: `Remove trigger`,
- content: `Are you sure you want remove trigger "${trigger.name}"?
Configuration will be updated immediately.
`,
+ content: `Are you sure you want remove trigger "${escapeHtml(trigger.name)}"?
Configuration will be updated immediately.
`,
icon: { type: 'icon', content: 'delete' },
});
if (details.reason !== 'done') return;
@@ -714,7 +714,7 @@ export class SystemStateService extends AsyncHandler {
public async removeZone(zone: PlaceZone) {
const details = await this.confirm({
title: 'Remove zone?',
- content: `Are you sure you want remove zone "${zone.name}" from the system?
Configuration will be updated immediately.`,
+ content: `Are you sure you want remove zone "${escapeHtml(zone.name)}" from the system?
Configuration will be updated immediately.`,
icon: { type: 'icon', content: 'delete' },
});
if (details.reason !== 'done') return;
diff --git a/src/app/triggers/trigger-state.service.ts b/src/app/triggers/trigger-state.service.ts
index a9935a51a..d60a93336 100644
--- a/src/app/triggers/trigger-state.service.ts
+++ b/src/app/triggers/trigger-state.service.ts
@@ -13,6 +13,7 @@ import {
updateTrigger,
} from '@placeos/ts-client';
+import { escapeHtml } from '../common/general';
import { ActiveItemService } from '../common/item.service';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
@@ -285,7 +286,7 @@ export class TriggerStateService {
title: i18n('TRIGGERS.REMOVE_INSTANCE_TITLE', { type }),
content: i18n('TRIGGERS.REMOVE_INSTANCE_MSG', {
type,
- name: instance.name,
+ name: escapeHtml(instance.name),
}),
icon: { type: 'icon', content: 'delete' },
},
diff --git a/src/app/ui/custom-fields/rich-text-input.component.ts b/src/app/ui/custom-fields/rich-text-input.component.ts
index 552b8d117..021a6ed3c 100644
--- a/src/app/ui/custom-fields/rich-text-input.component.ts
+++ b/src/app/ui/custom-fields/rich-text-input.component.ts
@@ -15,6 +15,7 @@ import { apiKey, token } from '@placeos/ts-client';
import SunEditor from 'suneditor';
import { font, fontSize, formatBlock, link, list } from 'suneditor/src/plugins';
import { AsyncHandler } from '../../common/async-handler.class';
+import { escapeHtml } from '../../common/general';
import { uploadFile } from '../../common/uploads';
@Component({
@@ -77,6 +78,12 @@ export class RichTextInputComponent
this.timeout('init', () => this._initialiseEditor());
}
+ public override ngOnDestroy() {
+ this._editor?.destroy();
+ delete this._editor;
+ super.ngOnDestroy();
+ }
+
/**
* Update the form field value
* @param new_value New value to set on the form field
@@ -97,7 +104,7 @@ export class RichTextInputComponent
if (this._editor) {
this._setAuth();
setTimeout(() => {
- this._editor.setContents(value);
+ this._editor?.setContents(value);
}, 100);
} else this.timeout('write', () => this.writeValue(value));
});
@@ -122,6 +129,7 @@ export class RichTextInputComponent
}
if (this._editor) {
this.unsub('changes');
+ this._editor.destroy();
_editor_el.innerHTML = '';
delete this._editor;
}
@@ -193,8 +201,8 @@ export class RichTextInputComponent
const link = `/api/engine/v2/uploads/${encodeURIComponent(upload.id)}/url`;
this._setAuth();
setTimeout(() => {
- this._editor.insertHTML(
- `
`,
+ this._editor?.insertHTML(
+ `
`,
);
}, 100);
});
@@ -217,12 +225,12 @@ export class RichTextInputComponent
this._setAuth();
setTimeout(() => {
if (is_image) {
- this._editor.insertHTML(
- `
`,
+ this._editor?.insertHTML(
+ `
`,
);
} else {
- this._editor.insertHTML(
- `${file.name}`,
+ this._editor?.insertHTML(
+ `${escapeHtml(file.name)}`,
);
}
}, 100);
diff --git a/src/app/ui/metadata-display.component.ts b/src/app/ui/metadata-display.component.ts
index 78d603353..1913c3432 100644
--- a/src/app/ui/metadata-display.component.ts
+++ b/src/app/ui/metadata-display.component.ts
@@ -22,6 +22,7 @@ import {
updateMetadata,
} from '@placeos/ts-client';
import { VERSION } from '../../env/version';
+import { escapeHtml } from '../common/general';
// import { SchemaStateService } from '../admin/schema-state.service';
import { AsyncHandler } from '../common/async-handler.class';
import { notifyError, notifySuccess } from '../common/notifications';
@@ -322,7 +323,7 @@ export class MetadataDisplayComponent
{
title: `Remove Metadata block`,
content: `
- Are you sure you want delete the metadata property "${field}"?
+ Are you sure you want delete the metadata property "${escapeHtml(field)}"?
`,
icon: { type: 'icon', content: 'delete' },
},
diff --git a/src/app/users/users-state.service.ts b/src/app/users/users-state.service.ts
index 2fbbe3ec7..6456cc622 100644
--- a/src/app/users/users-state.service.ts
+++ b/src/app/users/users-state.service.ts
@@ -12,6 +12,7 @@ import {
reviveUser,
updateGroupUser,
} from '@placeos/ts-client';
+import { escapeHtml } from '../common/general';
import { ActiveItemService } from '../common/item.service';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
@@ -178,7 +179,7 @@ export class UsersStateService {
{
title: i18n('USERS.GROUP_REMOVE'),
content: i18n('USERS.GROUP_REMOVE_MSG', {
- name: item.group?.name || item.group_id,
+ name: escapeHtml(item.group?.name || item.group_id),
}),
icon: { type: 'icon', content: 'delete' },
},
@@ -229,7 +230,9 @@ export class UsersStateService {
const details = await openConfirmModal(
{
title: i18n('USERS.REVIVE'),
- content: i18n('USERS.REVIVE_MSG', { name: item.name }),
+ content: i18n('USERS.REVIVE_MSG', {
+ name: escapeHtml(item.name),
+ }),
confirm_text: i18n('USERS.REVIVE'),
icon: { type: 'icon', content: 'restore_from_trash' },
},
diff --git a/src/app/zones/zones-state.service.ts b/src/app/zones/zones-state.service.ts
index 7adf5f9d5..bd9bc31eb 100644
--- a/src/app/zones/zones-state.service.ts
+++ b/src/app/zones/zones-state.service.ts
@@ -18,7 +18,7 @@ import {
updateGroupZone,
updateZone,
} from '@placeos/ts-client';
-import { unique } from '../common/general';
+import { escapeHtml, unique } from '../common/general';
import { ActiveItemService } from '../common/item.service';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
@@ -250,7 +250,7 @@ export class ZonesStateService {
const details = await openConfirmModal(
{
title: `Remove trigger`,
- content: `Are you sure you want remove trigger "${trigger.name}"?
Configuration will be updated immediately.
`,
+ content: `Are you sure you want remove trigger "${escapeHtml(trigger.name)}"?
Configuration will be updated immediately.
`,
icon: { type: 'icon', content: 'delete' },
},
this._dialog,
@@ -347,7 +347,7 @@ export class ZonesStateService {
{
title: i18n('ZONES.GROUP_REMOVE'),
content: i18n('ZONES.GROUP_REMOVE_MSG', {
- name: item.group?.name || item.group_id,
+ name: escapeHtml(item.group?.name || item.group_id),
}),
icon: { type: 'icon', content: 'delete' },
},
diff --git a/src/tests/common/general.spec.ts b/src/tests/common/general.spec.ts
index 4e6133ba8..cff523516 100644
--- a/src/tests/common/general.spec.ts
+++ b/src/tests/common/general.spec.ts
@@ -4,6 +4,7 @@ import {
csvToJson,
detectIE,
downloadFile,
+ escapeHtml,
eventToPoint,
flatten,
getItemWithKeys,
@@ -156,6 +157,19 @@ describe('general.ts utilities', () => {
});
});
+ describe('escapeHtml', () => {
+ it('escapes HTML special characters', () => {
+ expect(escapeHtml(`
&`)).toBe(
+ '<img src=x onerror="a('b')">&',
+ );
+ });
+
+ it('returns an empty string for null or undefined', () => {
+ expect(escapeHtml(null)).toBe('');
+ expect(escapeHtml(undefined)).toBe('');
+ });
+ });
+
describe('unique', () => {
it('should remove duplicate primitives', () => {
const arr = [1, 2, 2, 3, 3, 3];
diff --git a/src/tests/users/user-lifecycle.spec.ts b/src/tests/users/user-lifecycle.spec.ts
index 461891274..76f6b7138 100644
--- a/src/tests/users/user-lifecycle.spec.ts
+++ b/src/tests/users/user-lifecycle.spec.ts
@@ -39,7 +39,10 @@ vi.mock('../../app/common/hotkeys.service', () => ({
vi.mock('../../app/common/settings.service', () => ({
SettingsService: class {},
}));
-vi.mock('../../app/common/general', () => ({ log: vi.fn() }));
+vi.mock('../../app/common/general', async () => ({
+ ...(await vi.importActual('../../app/common/general')),
+ log: vi.fn(),
+}));
vi.mock('../../app/common/notifications', () => ({
notifyError: vi.fn(),
notifySuccess: vi.fn(),
From 9abbb751672133d1bb3abc0aa521aa92e07cc88a Mon Sep 17 00:00:00 2001
From: Alex Sorafumo
Date: Wed, 30 Sep 2026 11:02:21 +1000
Subject: [PATCH 02/10] fix(app): drop unused x-api-key URL param handler
setupPlace passes ignore_api_key, so ts-client never uses a stored API
key. The handler only wrote the key and trusted=true to localStorage,
and the trusted flag changes the OAuth flow.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/app/app.ts | 4 ----
1 file changed, 4 deletions(-)
diff --git a/src/app/app.ts b/src/app/app.ts
index 4c0d411c6..15ad09416 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -7,7 +7,6 @@ import {
invalidateToken,
isMock,
isOnline,
- setAPI_Key,
token,
} from '@placeos/ts-client';
@@ -169,9 +168,6 @@ export class AppComponent extends AsyncHandler implements OnInit {
this._locale?.setLocale(locale);
localStorage.setItem('BACKOFFICE.locale', locale);
}
- if (params.has('x-api-key')) {
- setAPI_Key(params.get('x-api-key'));
- }
});
setNotifyOutlet(this._snackbar);
setTranslationService(this._locale);
From 8adb6e364472c2c221fc7614ddc1cc3af70ed54e Mon Sep 17 00:00:00 2001
From: Alex Sorafumo
Date: Wed, 30 Sep 2026 11:03:19 +1000
Subject: [PATCH 03/10] fix(ui): stop bypassing href sanitising for stored URIs
Driver default_uri, repository uri and extension url links used the
safe 'url' pipe, which let javascript: URLs through. Let Angular
sanitise them instead. Reject script schemes in validateURI, and strip
repository credentials with the URL API for both link text and href.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/app/admin/extensions.component.ts | 7 +----
src/app/common/validation.ts | 6 ++++-
src/app/drivers/driver-about.component.ts | 4 +--
.../repositories/repositories.utilities.ts | 17 ++++++++++++
.../repository-about.component.ts | 8 +++---
src/tests/common/validation.spec.ts | 10 +++++++
.../repositories.utilities.spec.ts | 27 ++++++++++++++++++-
7 files changed, 64 insertions(+), 15 deletions(-)
diff --git a/src/app/admin/extensions.component.ts b/src/app/admin/extensions.component.ts
index 916737364..97d5b0124 100644
--- a/src/app/admin/extensions.component.ts
+++ b/src/app/admin/extensions.component.ts
@@ -16,7 +16,6 @@ import {
ConfirmModalData,
} from '../overlays/confirm-modal.component';
import { IconComponent } from '../ui/icon.component';
-import { SafePipe } from '../ui/pipes/safe.pipe';
import { SimpleTableComponent } from '../ui/simple-table.component';
import { TranslatePipe } from '../ui/translate.pipe';
import { AdminDataService } from './admin-data.service';
@@ -133,10 +132,7 @@ export interface BackofficeExtension {
-
+
{{ row.url }}
@@ -189,7 +185,6 @@ export interface BackofficeExtension {
MatFormFieldModule,
MatSelectModule,
FormsModule,
- SafePipe,
],
})
export class PlaceExtensionsComponent implements OnInit {
diff --git a/src/app/common/validation.ts b/src/app/common/validation.ts
index 83f10aae2..887c7fe42 100644
--- a/src/app/common/validation.ts
+++ b/src/app/common/validation.ts
@@ -12,11 +12,15 @@ export const validateIpAddress = (ctrl) =>
: { pattern: true }
: null;
+/** URI schemes that can run script when used as a link */
+const UNSAFE_URI_SCHEMES = ['javascript', 'vbscript', 'data'];
+
export const validateURI = (ctrl) => {
if (!ctrl.value) {
return null;
} else {
- return /\w+:(\/?\/?)[^\s]+?/gim.test(ctrl.value)
+ const match = /^\s*([a-z][a-z0-9+.-]*):(\/?\/?)\S+/i.exec(ctrl.value);
+ return match && !UNSAFE_URI_SCHEMES.includes(match[1].toLowerCase())
? null
: { pattern: true };
}
diff --git a/src/app/drivers/driver-about.component.ts b/src/app/drivers/driver-about.component.ts
index a21b563ba..95f74a0c1 100644
--- a/src/app/drivers/driver-about.component.ts
+++ b/src/app/drivers/driver-about.component.ts
@@ -11,7 +11,6 @@ import { SettingsFormComponent } from '../ui/forms/settings-form.component';
import { IconComponent } from '../ui/icon.component';
import { DateFromPipe } from '../ui/pipes/date-from.pipe';
import { MarkdownPipe } from '../ui/pipes/markdown.pipe';
-import { SafePipe } from '../ui/pipes/safe.pipe';
import { TranslatePipe } from '../ui/translate.pipe';
import { DriverStateService } from './driver-state.service';
@@ -34,7 +33,7 @@ import { DriverStateService } from './driver-state.service';
{{ item().default_uri }}
@@ -222,7 +221,6 @@ import { DriverStateService } from './driver-state.service';
DateFromPipe,
MarkdownPipe,
RouterModule,
- SafePipe,
MatProgressSpinnerModule,
AsyncPipe,
DatePipe,
diff --git a/src/app/repositories/repositories.utilities.ts b/src/app/repositories/repositories.utilities.ts
index 97a8ea95a..eccd57c07 100644
--- a/src/app/repositories/repositories.utilities.ts
+++ b/src/app/repositories/repositories.utilities.ts
@@ -54,3 +54,20 @@ export const applyRepositoryFormSchema: SchemaFn
= (
});
required(path.uri);
};
+
+/**
+ * Remove the username and password from a repository URI, so it is safe to
+ * show and to use as a link. URIs that do not parse (e.g. `git@host:repo`)
+ * only have any `//user:pass@` part removed.
+ */
+export function maskUriCredentials(uri: string): string {
+ if (!uri) return '';
+ try {
+ const url = new URL(uri);
+ url.username = '';
+ url.password = '';
+ return url.href;
+ } catch {
+ return uri.replace(/\/\/[^/\s]*@/, '//');
+ }
+}
diff --git a/src/app/repositories/repository-about.component.ts b/src/app/repositories/repository-about.component.ts
index 915fb05d1..fa721f93c 100644
--- a/src/app/repositories/repository-about.component.ts
+++ b/src/app/repositories/repository-about.component.ts
@@ -7,9 +7,9 @@ import { PlaceRepositoryType } from '@placeos/ts-client';
import { IconComponent } from '../ui/icon.component';
import { DateFromPipe } from '../ui/pipes/date-from.pipe';
import { MarkdownPipe } from '../ui/pipes/markdown.pipe';
-import { SafePipe } from '../ui/pipes/safe.pipe';
import { TranslatePipe } from '../ui/translate.pipe';
import { RepositoriesStateService } from './repositories-state.service';
+import { maskUriCredentials } from './repositories.utilities';
@Component({
selector: 'repository-about',
@@ -108,7 +108,7 @@ import { RepositoriesStateService } from './repositories-state.service';
{{ repo_uri() || 'No URI set' }}
@@ -217,7 +217,6 @@ import { RepositoriesStateService } from './repositories-state.service';
MatProgressSpinnerModule,
MatRippleModule,
MatTooltipModule,
- SafePipe,
DateFromPipe,
AsyncPipe,
DatePipe,
@@ -238,8 +237,9 @@ export class RepositoryAboutComponent {
? `${location.origin}/${this.item()?.folder_name}/`
: `${location.hash}`,
);
+ /** Repository URI without credentials, used for both the text and link */
public readonly repo_uri = computed(() =>
- this.item()?.uri.replace(/\/[a-zA-Z0-9\-.:]*@/, '/...@'),
+ maskUriCredentials(this.item()?.uri),
);
public readonly is_interface = computed(
() => this.item()?.type === PlaceRepositoryType.Interface,
diff --git a/src/tests/common/validation.spec.ts b/src/tests/common/validation.spec.ts
index 99f9cb112..d153ac379 100644
--- a/src/tests/common/validation.spec.ts
+++ b/src/tests/common/validation.spec.ts
@@ -127,6 +127,16 @@ describe('validation.ts utilities', () => {
const ctrl = { value: '/path/to/file' };
expect(validateURI(ctrl)).toEqual({ pattern: true });
});
+
+ it.each([
+ 'javascript:alert(1)',
+ ' JavaScript://%0aalert(1)',
+ 'vbscript:msgbox(1)',
+ 'data:text/html,',
+ 'text before javascript:alert(1)',
+ ])('should reject script URI %s', (value) => {
+ expect(validateURI({ value })).toEqual({ pattern: true });
+ });
});
describe('edge cases', () => {
diff --git a/src/tests/repositories/repositories.utilities.spec.ts b/src/tests/repositories/repositories.utilities.spec.ts
index ded337952..7f1801dea 100644
--- a/src/tests/repositories/repositories.utilities.spec.ts
+++ b/src/tests/repositories/repositories.utilities.spec.ts
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from 'vitest';
-import { generateRepositoryFormModel } from '../../app/repositories/repositories.utilities';
+import {
+ generateRepositoryFormModel,
+ maskUriCredentials,
+} from '../../app/repositories/repositories.utilities';
const mocks = vi.hoisted(() => ({
PlaceRepositoryType: {
@@ -57,4 +60,26 @@ describe('repositories.utilities', () => {
});
});
});
+
+ describe('maskUriCredentials', () => {
+ it('removes a username and password with special characters', () => {
+ expect(
+ maskUriCredentials(
+ 'https://my_user+1:p%40ss_w%2Bd@github.com/org/repo.git',
+ ),
+ ).toBe('https://github.com/org/repo.git');
+ });
+
+ it('keeps a URI without credentials', () => {
+ expect(maskUriCredentials('https://github.com/org/repo')).toBe(
+ 'https://github.com/org/repo',
+ );
+ });
+
+ it('removes credentials from a URI that does not parse', () => {
+ expect(maskUriCredentials('//user:pass@host/repo')).toBe(
+ '//host/repo',
+ );
+ });
+ });
});
From 33b3e69019c8e55e35678bd17a746e2fa099ae38 Mon Sep 17 00:00:00 2001
From: Alex Sorafumo
Date: Wed, 30 Sep 2026 11:03:45 +1000
Subject: [PATCH 04/10] fix(admin): sandbox signage plugin embed to an opaque
origin
Plugin URIs are usually same origin, so allow-same-origin with
allow-scripts gave plugins full access to the backoffice session.
Drop allow-same-origin, match plugin messages on event.source as the
probe already does, and allow only http(s) plugin URIs.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../signage-plugin-embed.component.ts | 27 ++++++++++---------
.../signage-plugin-embed.component.spec.ts | 6 +++++
2 files changed, 20 insertions(+), 13 deletions(-)
diff --git a/src/app/admin/signage-plugins/signage-plugin-embed.component.ts b/src/app/admin/signage-plugins/signage-plugin-embed.component.ts
index fbbb8839b..b96b85cd9 100644
--- a/src/app/admin/signage-plugins/signage-plugin-embed.component.ts
+++ b/src/app/admin/signage-plugins/signage-plugin-embed.component.ts
@@ -18,14 +18,18 @@ import { SafePipe } from '../../ui/pipes/safe.pipe';
export const SIGNAGE_PLUGIN_API_VERSION = 'signage-plugin/v1';
-/** Resolve a plugin URI the same way as the iframe element. */
+/**
+ * Resolve a plugin URI the same way as the iframe element.
+ * Returns `null` for invalid URIs and for schemes other than http(s).
+ */
export function resolveSignagePluginUrl(
uri: string,
base_uri: string,
): URL | null {
if (!uri) return null;
try {
- return new URL(uri, base_uri);
+ const url = new URL(uri, base_uri);
+ return ['http:', 'https:'].includes(url.protocol) ? url : null;
} catch {
return null;
}
@@ -87,7 +91,7 @@ export type PluginErrorPayload = {
@if (plugin_url(); as plugin_url) {