fix: PUT-1398 (#3478)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Daniel Salazar
2026-07-30 01:42:40 -07:00
committed by GitHub
parent 8a711e0254
commit 5a157197b6
5 changed files with 414 additions and 81 deletions
@@ -92,6 +92,11 @@ const uniqueName = (prefix: string) =>
const uniqueIndexUrl = () =>
`https://example-${Math.random().toString(36).slice(2, 10)}.test/`;
// 1x1 transparent PNG. The `icon` write path validates the decoded payload,
// not just the declared MIME, so icon fixtures must be real images.
const MINIMAL_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const createApp = async (
actor: Actor,
overrides: Record<string, unknown> = {},
@@ -718,7 +723,9 @@ describe('AppController GET /app-icon/:app_uid', () => {
it('decodes a data URL icon and serves the declared MIME', async () => {
const owner = await makeUser();
const png = Buffer.from('mock-png-bytes');
// Real PNG bytes: the write path sniffs the decoded payload and
// rejects anything that isn't the image type it claims to be.
const png = Buffer.from(MINIMAL_PNG_BASE64, 'base64');
const dataUrl = `data:image/png;base64,${png.toString('base64')}`;
const app = await createApp(owner.actor, { icon: dataUrl });
@@ -740,7 +747,7 @@ describe('AppController GET /app-icon/:app_uid', () => {
// than the redirect path that serves the same resource.
it('caches an inline data-URL icon as long as the redirect path', async () => {
const owner = await makeUser();
const dataUrl = `data:image/png;base64,${Buffer.from('x').toString('base64')}`;
const dataUrl = `data:image/png;base64,${MINIMAL_PNG_BASE64}`;
const app = await createApp(owner.actor, { icon: dataUrl });
const { res, captured } = makeRes();
+18 -20
View File
@@ -25,10 +25,10 @@ import {
DEFAULT_TEMP_SUBSCRIPTION,
} from '../../services/metering/consts.js';
import {
ICON_DATA_URL_MIME_ALLOWLIST,
isAppIconEndpointUrl,
isRawBase64ImageString,
normalizeRawBase64ImageString,
validateIconDataUrl,
} from '../../util/appIcon.js';
import {
buildHostedBackingDenial,
@@ -582,8 +582,10 @@ export class AppDriver extends PuterDriver {
// Accepted shapes (mirrors v1's `image-base64` proptype so
// puter-js callers keep working):
// 1. Empty string — unset
// 2. Raw base64 (no prefix) — normalized to a PNG data URL
// 3. `data:image/<mime>;…` with an allow-listed MIME
// 2. Raw base64 (no prefix) of a real image — normalized to a
// data URL carrying the sniffed MIME
// 3. `data:image/<mime>;base64,<image>` with an allow-listed
// MIME that matches the decoded payload
// 4. `/app-icon/<uid>` endpoint URL (relative, or absolute
// on a host we control)
// Anything else (including arbitrary http(s) URLs) is rejected:
@@ -594,24 +596,20 @@ export class AppDriver extends PuterDriver {
// Raw base64 → wrap as data URL (v1 parity)
if (isRawBase64ImageString(iconStr)) {
iconStr = normalizeRawBase64ImageString(iconStr);
} else if (iconStr.startsWith('data:')) {
const semi = iconStr.indexOf(';');
const comma = iconStr.indexOf(',');
const mimeEnd =
semi !== -1 && (comma === -1 || semi < comma)
? semi
: comma;
const mime =
mimeEnd !== -1
? iconStr.slice(5, mimeEnd).toLowerCase()
: '';
if (!ICON_DATA_URL_MIME_ALLOWLIST.includes(mime)) {
throw new HttpError(
400,
'`icon` data URL must use an image MIME type',
{ legacyCode: 'bad_request' },
);
}
if (iconStr.startsWith('data:')) {
// Validates the whole URL, payload included — a MIME
// prefix check alone let arbitrary text (quotes, tags)
// through, which a Dev Center template then interpolated
// into markup.
const verdict = validateIconDataUrl(iconStr);
if (!verdict.ok) {
throw new HttpError(400, `\`icon\` ${verdict.reason}`, {
legacyCode: 'bad_request',
});
}
// Store the canonical form, not the caller's spelling.
iconStr = verdict.normalized;
} else if (!isAppIconEndpointUrl(iconStr, this.config)) {
throw new HttpError(
400,
+142
View File
@@ -607,6 +607,148 @@ describe('AppDriver.create additional branches', () => {
expect(created.icon).toBe(png);
});
// The write path once validated only the MIME prefix, so an
// allow-listed prefix plus arbitrary text was stored verbatim and later
// interpolated into a Dev Center template — stored XSS in a godmode app
// that carries the user's session token. Reachable with an app-under-user
// token, the lowest-privilege credential we issue.
describe('icon data URL payload validation', () => {
const ATTACK_PAYLOAD =
'data:image/png;base64,iVBORw0KGgo=" a5x="1"><img src=x onerror=window.__A5APP=1>';
const createWithIcon = async (icon: string, label: string) => {
const { actor } = await makeUser();
return withActor(actor, () =>
driver.create({
object: {
name: uniqueName(label),
title: 't',
index_url: uniqueIndexUrl(),
icon,
},
}),
);
};
it('rejects the reported breakout payload', async () => {
await expect(
createWithIcon(ATTACK_PAYLOAD, 'xss-icon'),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects the breakout payload on update, not just create', async () => {
const { actor } = await makeUser();
const created = await withActor(actor, () =>
driver.create({
object: {
name: uniqueName('xss-upd'),
title: 't',
index_url: uniqueIndexUrl(),
},
}),
);
await expect(
withActor(actor, () =>
driver.update({
uid: created.uid,
object: { icon: ATTACK_PAYLOAD },
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects an allow-listed MIME whose payload is not an image', async () => {
// Valid base64, decodes cleanly — just isn't a PNG.
const notAnImage = `data:image/png;base64,${Buffer.from(
'not an image at all',
).toString('base64')}`;
await expect(
createWithIcon(notAnImage, 'notimg-icon'),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a payload whose bytes contradict the declared MIME', async () => {
const pngBytes =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
await expect(
createWithIcon(
`data:image/gif;base64,${pngBytes}`,
'mismatch-icon',
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a percent-encoded (non-base64) data URL', async () => {
// The only shape that can carry literal `<` and `"`.
await expect(
createWithIcon(
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%3C/svg%3E',
'pct-icon',
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects base64 with smuggled non-base64 characters', async () => {
// `Buffer.from(…,'base64')` silently drops these; the
// round-trip check is what catches them.
await expect(
createWithIcon(
'data:image/png;base64,iVBORw0KGgo=<script>alert(1)</script>',
'smuggle-icon',
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('accepts a base64 SVG icon and stores it canonically', async () => {
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>',
).toString('base64');
const created = await createWithIcon(
`data:image/svg+xml;base64,${svg}`,
'svg-icon',
);
expect(created.icon).toBe(`data:image/svg+xml;base64,${svg}`);
});
it('accepts image/jpg as an alias of image/jpeg', async () => {
// Minimal JPEG SOI + APP0 header — enough to sniff.
const jpeg = Buffer.concat([
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
Buffer.from('0000JFIF'),
]).toString('base64');
const created = await createWithIcon(
`data:image/jpg;base64,${jpeg}`,
'jpg-icon',
);
expect(String(created.icon).startsWith('data:image/jpg;base64,')).toBe(
true,
);
});
it('strips line wrapping from an otherwise valid payload', async () => {
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const wrapped = `${png.slice(0, 40)}\n${png.slice(40)}`;
const created = await createWithIcon(
`data:image/png;base64,${wrapped}`,
'wrapped-icon',
);
expect(created.icon).toBe(`data:image/png;base64,${png}`);
});
it('rejects raw base64 that does not decode to an image', async () => {
// v1 wrapped any base64 as image/png regardless of content.
await expect(
createWithIcon(
Buffer.from('definitely not an image payload').toString(
'base64',
),
'rawtext-icon',
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
it('normalizes a raw-base64 icon into a data: URL', async () => {
const { actor } = await makeUser();
// Raw base64 of a 1x1 PNG (no data: prefix)
+239 -53
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
// Always routes through the backend `/app-icon/<uid>/<size>` endpoint rather
@@ -56,6 +57,179 @@ interface TrustedIconHostConfig {
}
const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/]*={0,2}$/;
// `data:<type>/<subtype>[;param[=value]]…,<payload>`. The parameter list is
// matched as a group of its own so it can be checked exhaustively — the
// previous prefix-scan only looked at the bytes before the first `;` or `,`
// and never inspected the payload at all.
const DATA_URL_REGEX =
/^data:([a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*)((?:;[a-z0-9!#$&^_.+-]+(?:=[^;,]*)?)*),([\s\S]*)$/i;
// Cap on how far into a payload we look for the `<svg` root element. SVG is
// the one allow-listed type without a fixed-offset magic number; a file that
// buries its root past this much leading comment/PI text is not something we
// need to accept.
const SVG_SNIFF_WINDOW = 8 * 1024;
/**
* Decode strict base64 no whitespace, correct padding, and byte-for-byte
* round-trip. `Buffer.from(s, 'base64')` silently skips characters it doesn't
* recognise, so `iVBORw0KGgo=" onerror=alert(1)` decodes without complaint; the
* round-trip is what rejects it.
*/
function decodeStrictBase64(value: string): Buffer | null {
if (!BASE64_CHARS_REGEX.test(value)) return null;
if (value.length === 0 || value.length % 4 !== 0) return null;
try {
const decoded = Buffer.from(value, 'base64');
if (decoded.length === 0) return null;
const stripped = value.replace(/=+$/, '');
const reencoded = decoded.toString('base64').replace(/=+$/, '');
return stripped === reencoded ? decoded : null;
} catch {
return null;
}
}
/** `image/jpg` is a common misspelling of `image/jpeg`; treat them as one. */
function canonicalImageMime(mime: string): string {
return mime === 'image/jpg' ? 'image/jpeg' : mime;
}
function looksLikeSvg(bytes: Buffer): boolean {
let head = bytes.subarray(0, SVG_SNIFF_WINDOW).toString('utf8');
if (head.charCodeAt(0) === 0xfeff) head = head.slice(1);
// Must open as markup (rules out arbitrary text that merely mentions
// `<svg` somewhere), and must actually contain an `<svg` root.
if (!head.trimStart().startsWith('<')) return false;
return /<svg[\s/>]/i.test(head);
}
/**
* Identify image bytes by content, returning a canonical allow-listed MIME type
* or null. Signature-based: the declared MIME on a data URL is caller input and
* cannot be trusted to describe the payload.
*/
export function sniffImageMime(bytes: Buffer): string | null {
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47 &&
bytes[4] === 0x0d &&
bytes[5] === 0x0a &&
bytes[6] === 0x1a &&
bytes[7] === 0x0a
) {
return 'image/png';
}
if (
bytes.length >= 3 &&
bytes[0] === 0xff &&
bytes[1] === 0xd8 &&
bytes[2] === 0xff
) {
return 'image/jpeg';
}
if (bytes.length >= 6) {
const head = bytes.subarray(0, 6).toString('latin1');
if (head === 'GIF87a' || head === 'GIF89a') return 'image/gif';
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
bytes.subarray(8, 12).toString('latin1') === 'WEBP'
) {
return 'image/webp';
}
if (looksLikeSvg(bytes)) return 'image/svg+xml';
return null;
}
export interface IconDataUrlVerdict {
ok: boolean;
/** Human-readable failure clause, suffixed onto "`icon` …". */
reason?: string;
/** Canonical MIME sniffed from the payload (on success). */
mime?: string;
/** Canonical, whitespace-stripped data URL to store (on success). */
normalized?: string;
}
/**
* Validate an `icon` data URL end to end MIME, encoding, _and_ payload.
*
* The write path used to check only the MIME prefix, so everything after the
* comma was stored verbatim. `data:image/png;base64,` is a valid prefix, which
* meant up to 5 MB of arbitrary text including `"` and `<` could be parked
* in the icon column and later interpolated into a Dev Center template stored
* XSS in a first-party app that holds the user's session token.
*
* Requirements, in order:
*
* - Well-formed `data:` URL with an allow-listed image MIME type
* - `;base64` and nothing else for the parameter list. Every Puter client
* produces base64 (`FileReader.readAsDataURL` always does); requiring it is
* what makes the payload checkable at all. Percent-encoded payloads the
* only shape that can carry raw `<`/`"` are rejected; callers with literal
* SVG markup must base64 it.
* - Strictly valid base64 (round-tripped, so smuggled non-base64 bytes fail)
* - Decoded bytes that sniff as an image whose type matches the declaration
*/
export function validateIconDataUrl(value: unknown): IconDataUrlVerdict {
if (typeof value !== 'string') {
return { ok: false, reason: 'must be a string' };
}
const match = DATA_URL_REGEX.exec(value.trim());
if (!match) {
return { ok: false, reason: 'is not a well-formed data: URL' };
}
const declaredMime = match[1].toLowerCase();
const params = match[2].toLowerCase();
const payload = match[3];
if (
!(ICON_DATA_URL_MIME_ALLOWLIST as readonly string[]).includes(
declaredMime,
)
) {
return { ok: false, reason: 'data URL must use an image MIME type' };
}
if (params !== ';base64') {
return {
ok: false,
reason: 'data URL must be base64-encoded (`;base64`) with no other parameters',
};
}
// Line-wrapped base64 is tolerated, but only whitespace may be stripped —
// any other character outside the base64 alphabet fails below.
const compact = payload.replace(/[\s]+/g, '');
const bytes = decodeStrictBase64(compact);
if (!bytes) {
return { ok: false, reason: 'data URL payload is not valid base64' };
}
const sniffed = sniffImageMime(bytes);
if (!sniffed) {
return {
ok: false,
reason: 'data URL payload is not a recognized image',
};
}
if (sniffed !== canonicalImageMime(declaredMime)) {
return {
ok: false,
reason: `data URL declares ${declaredMime} but the payload is ${sniffed}`,
};
}
return {
ok: true,
mime: sniffed,
normalized: `data:${declaredMime};base64,${compact}`,
};
}
const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/[^/?#]+(?:\/\d+)?\/?$/;
// Direct subdomain file shape written by AppIconService:
// /app-<uid>.png (original)
@@ -64,51 +238,62 @@ const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/[^/?#]+(?:\/\d+)?\/?$/;
const APP_ICON_SUBDOMAIN_PATH_REGEX = /^\/app-[A-Za-z0-9_-]+(?:-\d+)?\.png$/;
/**
* v1-compatible raw-base64 detector. Legacy puter-js callers pass the
* base64 payload without a `data:` prefix; v1 accepted it and normalized
* to `data:image/png;base64,<raw>` before storage. We mirror that here
* so clients that worked on v1 keep working.
*
* Rejects anything shorter than 16 chars, not aligned to base64 length,
* or that doesn't round-trip through Buffer catches random strings
* that happen to match the charset.
* Decode a bare base64 string and sniff it, or null if it isn't base64 at all
* or doesn't decode to a recognized image.
*/
export function isRawBase64ImageString(value: unknown): value is string {
if (typeof value !== 'string') return false;
function sniffRawBase64Image(
value: unknown,
): { bytes: Buffer; mime: string; compact: string } | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (trimmed.length < 16) return false;
if (!RAW_BASE64_REGEX.test(trimmed)) return false;
if (trimmed.length % 4 !== 0) return false;
try {
const decoded = Buffer.from(trimmed, 'base64');
if (decoded.length === 0) return false;
const stripped = trimmed.replace(/=+$/, '');
const reencoded = decoded.toString('base64').replace(/=+$/, '');
return stripped === reencoded;
} catch {
return false;
}
if (trimmed.length < 16) return null;
if (!RAW_BASE64_REGEX.test(trimmed)) return null;
const bytes = decodeStrictBase64(trimmed);
if (!bytes) return null;
const mime = sniffImageMime(bytes);
if (!mime) return null;
return { bytes, mime, compact: trimmed };
}
/** Wrap raw base64 in a `data:image/png;base64,…` URL; pass other values through. */
/**
* V1-compatible raw-base64 detector. Legacy puter-js callers pass the base64
* payload without a `data:` prefix; v1 accepted it and normalized to
* `data:image/png;base64,<raw>` before storage. We mirror that here so clients
* that worked on v1 keep working.
*
* Rejects anything shorter than 16 chars, not aligned to base64 length, or that
* doesn't round-trip through Buffer catches random strings that happen to
* match the charset. Also rejects base64 that decodes to something other than a
* recognized image: v1 wrapped any base64 as `image/png` regardless of content,
* which let non-image bytes into the icon column under an image MIME type.
*/
export function isRawBase64ImageString(value: unknown): value is string {
return sniffRawBase64Image(value) !== null;
}
/**
* Wrap raw base64 in a `data:<sniffed-mime>;base64,…` URL; pass other values
* through unchanged (the caller then rejects them a bare string that isn't a
* valid image is not one of the accepted `icon` shapes).
*/
export function normalizeRawBase64ImageString(value: string): string {
const trimmed = value.trim();
if (!isRawBase64ImageString(trimmed)) return value;
return `data:image/png;base64,${trimmed}`;
const sniffed = sniffRawBase64Image(value);
if (!sniffed) return value;
return `data:${sniffed.mime};base64,${sniffed.compact}`;
}
/**
* Whether `value` is a reference we own accepts two shapes:
* - `/app-icon/<uid>(/<size>)?` : the AppController endpoint
* - `/app-<uid>(-<size>)?.png` : the file written by
* AppIconService onto the
* `puter-app-icons` subdomain
*
* Relative paths must use the endpoint shape (the subdomain-file shape
* is only meaningful when paired with a trusted host). Absolute URLs
* accept either shape but only on a trusted host without the host
* check an authenticated user could set `icon` to an attacker URL and
* turn `/app-icon/:uid` into a Puter-branded open redirector.
* - `/app-icon/<uid>(/<size>)?` : the AppController endpoint
* - `/app-<uid>(-<size>)?.png` : the file written by AppIconService onto the
* `puter-app-icons` subdomain
*
* Relative paths must use the endpoint shape (the subdomain-file shape is only
* meaningful when paired with a trusted host). Absolute URLs accept either
* shape but only on a trusted host without the host check an authenticated
* user could set `icon` to an attacker URL and turn `/app-icon/:uid` into a
* Puter-branded open redirector.
*/
export function isAppIconEndpointUrl(
value: string,
@@ -137,17 +322,18 @@ export function isAppIconEndpointUrl(
/**
* Whether `url` points at a host we control for app-icon hosting.
*
* Used to gate both the legacy redirect fallback in `/app-icon/:uid` and
* the write-path validator in AppDriver without this check an
* authenticated user can set `icon` to an arbitrary attacker URL and
* turn the unauthenticated `/app-icon/:uid` route into a Puter-branded
* open redirector (cached publicly for 15 minutes).
* Used to gate both the legacy redirect fallback in `/app-icon/:uid` and the
* write-path validator in AppDriver without this check an authenticated user
* can set `icon` to an arbitrary attacker URL and turn the unauthenticated
* `/app-icon/:uid` route into a Puter-branded open redirector (cached publicly
* for 15 minutes).
*
* Accepts:
* - `puter-app-icons.<static_hosting_domain>` (and _alt)
* - The configured `api_base_url` host (AppIconService rewrites icon
* columns to `${api_base_url}/app-icon/<uid>`, so it must be trusted
* or round-tripped writes would fail validation).
*
* - `puter-app-icons.<static_hosting_domain>` (and _alt)
* - The configured `api_base_url` host (AppIconService rewrites icon columns to
* `${api_base_url}/app-icon/<uid>`, so it must be trusted or round-tripped
* writes would fail validation).
*/
export function isTrustedIconHost(
url: string,
+6 -6
View File
@@ -1378,7 +1378,7 @@ $(document).on('click', '.delete-app-settings', async function (e) {
const app_data = await puter.apps.get(app_name, { icon_size: 16 });
if ( app_data.metadata?.locked ) {
puter.ui.alert(`<strong>${app_data.title}</strong> is locked and cannot be deleted.`, [
puter.ui.alert(`<strong>${html_encode(app_data.title)}</strong> is locked and cannot be deleted.`, [
{
label: 'Ok',
},
@@ -1568,7 +1568,7 @@ function generate_app_card (app) {
background-position: center;
background-repeat: no-repeat;
background-size: 92%;
background-image: url(${app.icon === null ? './img/app.svg' : app.icon});
background-image: url(${html_encode(app.icon === null ? './img/app.svg' : app.icon)});
width: 60px;
height: 60px;
margin-right: 10px;
@@ -2457,7 +2457,7 @@ $(document).on('click', '.delete-apps-btn', async function (e) {
if ( app_data.metadata?.locked ) {
if ( apps.length === 1 ) {
puter.ui.alert(`<strong>${app_data.title}</strong> is locked and cannot be deleted.`, [
puter.ui.alert(`<strong>${html_encode(app_data.title)}</strong> is locked and cannot be deleted.`, [
{
label: 'Ok',
},
@@ -2468,7 +2468,7 @@ $(document).on('click', '.delete-apps-btn', async function (e) {
break;
}
let resp = await puter.ui.alert(`<strong>${app_data.title}</strong> is locked and cannot be deleted.`, [
let resp = await puter.ui.alert(`<strong>${html_encode(app_data.title)}</strong> is locked and cannot be deleted.`, [
{
label: 'Skip and Continue',
value: 'Continue',
@@ -3142,7 +3142,7 @@ function app_context_menu (app_name, app_title, app_uid) {
overwrite: false,
appUID: app_uid,
}).then(async (uploaded) => {
puter.ui.alert(`<strong>${app_title}</strong> shortcut has been added to your desktop.`, [
puter.ui.alert(`<strong>${html_encode(app_title)}</strong> shortcut has been added to your desktop.`, [
{
label: 'Ok',
type: 'primary',
@@ -3182,7 +3182,7 @@ async function attempt_delete_app (app_name, app_title, app_uid) {
const app_data = await puter.apps.get(app_name, { icon_size: 16 });
if ( app_data.metadata?.locked ) {
puter.ui.alert(`<strong>${app_data.title}</strong> is locked and cannot be deleted.`, [
puter.ui.alert(`<strong>${html_encode(app_data.title)}</strong> is locked and cannot be deleted.`, [
{
label: 'Ok',
},