From 83767b25e018fa5e3439c0be509aa2e93a1f5f67 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Mon, 16 Feb 2026 11:04:57 -0800 Subject: [PATCH 1/2] fix: app icon origin --- .../src/modules/data-access/AppService.js | 80 ++++++++++++- .../modules/data-access/AppService.test.js | 110 +++++++++++++++++- src/backend/src/om/proptypes/__all__.js | 80 +++++++++++-- src/backend/src/om/proptypes/__all__.test.js | 21 +++- 4 files changed, 268 insertions(+), 23 deletions(-) diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 35e8746738..58e7bd5b9e 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -21,17 +21,81 @@ import { } from './lib/validation.js'; const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/[^/?#]+\/\d+\/?$/; +const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; + +const getCanonicalAppIconBaseUrl = () => { + const candidate = [config.api_base_url, config.origin] + .find(value => typeof value === 'string' && value.trim()); + if ( ! candidate ) return null; + try { + return (new URL(candidate)).origin; + } catch { + return null; + } +}; + +const getAllowedAppIconOrigins = () => { + const origins = new Set(); + for ( const candidate of [config.api_base_url, config.origin] ) { + if ( typeof candidate !== 'string' || !candidate ) continue; + try { + origins.add((new URL(candidate)).origin); + } catch { + // Ignore invalid config values. + } + } + return origins; +}; const isAppIconEndpointPath = (value) => { if ( typeof value !== 'string' ) return false; + const trimmed = value.trim(); + if ( ! trimmed ) return false; + try { - const pathname = new URL(value, 'http://localhost').pathname; + const parsed = new URL(trimmed, 'http://localhost'); + const pathname = parsed.pathname; return APP_ICON_ENDPOINT_PATH_REGEX.test(pathname); } catch { return false; } }; +const isAllowedAppIconEndpointUrl = value => { + if ( ! isAppIconEndpointPath(value) ) return false; + + const trimmed = value.trim(); + const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); + if ( ! isAbsoluteUrl ) { + return true; + } + + try { + const parsed = new URL(trimmed, 'http://localhost'); + return getAllowedAppIconOrigins().has(parsed.origin); + } catch { + return false; + } +}; + +const migrateRelativeAppIconEndpointUrl = value => { + if ( typeof value !== 'string' ) return value; + const trimmed = value.trim(); + if ( ! isAppIconEndpointPath(trimmed) ) return value; + + const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); + if ( isAbsoluteUrl ) return trimmed; + + const baseUrl = getCanonicalAppIconBaseUrl(); + if ( ! baseUrl ) return trimmed; + + try { + return new URL(trimmed, `${baseUrl}/`).toString(); + } catch { + return trimmed; + } +}; + /** * AppService contains an instance using the repository pattern */ @@ -442,12 +506,15 @@ export default class AppService extends BaseService { } if ( object.icon !== undefined && object.icon !== null ) { + if ( typeof object.icon === 'string' ) { + object.icon = migrateRelativeAppIconEndpointUrl(object.icon); + } if ( typeof object.icon === 'string' && object.icon.startsWith('data:') ) { validate_image_base64(object.icon, { key: 'icon' }); - } else if ( isAppIconEndpointPath(object.icon) ) { + } else if ( isAllowedAppIconEndpointUrl(object.icon) ) { // Allow existing relative app icon endpoint references. } else { - validate_url(object.icon, { key: 'icon', maxlen: 3000 }); + throw APIError.create('field_invalid', null, { key: 'icon' }); } } @@ -691,12 +758,15 @@ export default class AppService extends BaseService { } if ( object.icon !== undefined && object.icon !== null ) { + if ( typeof object.icon === 'string' ) { + object.icon = migrateRelativeAppIconEndpointUrl(object.icon); + } if ( typeof object.icon === 'string' && object.icon.startsWith('data:') ) { validate_image_base64(object.icon, { key: 'icon' }); - } else if ( isAppIconEndpointPath(object.icon) ) { + } else if ( isAllowedAppIconEndpointUrl(object.icon) ) { // Allow existing relative app icon endpoint references. } else { - validate_url(object.icon, { key: 'icon', maxlen: 3000 }); + throw APIError.create('field_invalid', null, { key: 'icon' }); } } diff --git a/src/backend/src/modules/data-access/AppService.test.js b/src/backend/src/modules/data-access/AppService.test.js index f859e6ba42..6338bb1a18 100644 --- a/src/backend/src/modules/data-access/AppService.test.js +++ b/src/backend/src/modules/data-access/AppService.test.js @@ -37,6 +37,8 @@ vi.mock('../../config.js', () => ({ app_name_regex: /^[a-z0-9-]+$/, app_title_max_length: 200, static_hosting_domain: 'puter.site', + origin: 'https://puter.localhost', + api_base_url: 'https://api.puter.localhost', }, })); @@ -824,7 +826,7 @@ describe('AppService', () => { })); }); - it('should allow relative app-icon endpoint path for icon', async () => { + it('should migrate relative app-icon endpoint path to absolute URL on create', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockResolvedValue([createMockAppRow()]); validate_url.mockImplementation((_value, { key }) => { @@ -846,11 +848,64 @@ describe('AppService', () => { expect(mockEventService.emit).toHaveBeenCalledWith( 'app.new-icon', expect.objectContaining({ - data_url: '/app-icon/app-uid-123/64', + data_url: 'https://api.puter.localhost/app-icon/app-uid-123/64', })); expect(validate_url).toHaveBeenCalledWith('https://example.com', expect.objectContaining({ key: 'index_url' })); }); + it('should allow absolute app-icon endpoint URL on API origin', async () => { + setupContextForWrite(createMockUserActor(1)); + mockDb.read.mockResolvedValue([createMockAppRow()]); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.create.call(appService, { + object: { + name: 'test-app', + title: 'Test', + index_url: 'https://example.com', + icon: 'https://api.puter.localhost/app-icon/app-uid-123/64', + }, + }); + + expect(mockEventService.emit).toHaveBeenCalledWith( + 'app.new-icon', + expect.objectContaining({ + data_url: 'https://api.puter.localhost/app-icon/app-uid-123/64', + })); + }); + + it('should reject foreign absolute app-icon endpoint URL on create', async () => { + setupContextForWrite(createMockUserActor(1)); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await expect(crudQ.create.call(appService, { + object: { + name: 'test-app', + title: 'Test', + index_url: 'https://example.com', + icon: 'https://evil.example/app-icon/app-uid-123/64', + }, + })).rejects.toMatchObject({ + fields: { code: 'field_invalid', key: 'icon' }, + }); + }); + + it('should reject non app-icon URL icon on create', async () => { + setupContextForWrite(createMockUserActor(1)); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await expect(crudQ.create.call(appService, { + object: { + name: 'test-app', + title: 'Test', + index_url: 'https://example.com', + icon: 'https://example.com/webhook', + }, + })).rejects.toMatchObject({ + fields: { code: 'field_invalid', key: 'icon' }, + }); + }); + it('should handle filetype_associations', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockResolvedValue([createMockAppRow()]); @@ -1105,7 +1160,7 @@ describe('AppService', () => { })); }); - it('should allow relative app-icon endpoint path when updating icon', async () => { + it('should migrate relative app-icon endpoint path to absolute URL on update', async () => { setupContextForWrite(createMockUserActor(1)); validate_url.mockImplementation((_value, { key }) => { if ( key === 'icon' ) { @@ -1125,10 +1180,57 @@ describe('AppService', () => { 'app.new-icon', expect.objectContaining({ app_uid: 'app-uid-123', - data_url: '/app-icon/app-uid-123/64', + data_url: 'https://api.puter.localhost/app-icon/app-uid-123/64', + })); + }); + + it('should allow absolute app-icon endpoint URL on API origin when updating icon', async () => { + setupContextForWrite(createMockUserActor(1)); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + icon: 'https://api.puter.localhost/app-icon/app-uid-123/64', + }, + }); + + expect(mockEventService.emit).toHaveBeenCalledWith( + 'app.new-icon', + expect.objectContaining({ + app_uid: 'app-uid-123', + data_url: 'https://api.puter.localhost/app-icon/app-uid-123/64', })); }); + it('should reject foreign absolute app-icon endpoint URL on update', async () => { + setupContextForWrite(createMockUserActor(1)); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await expect(crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + icon: 'https://evil.example/app-icon/app-uid-123/64', + }, + })).rejects.toMatchObject({ + fields: { code: 'field_invalid', key: 'icon' }, + }); + }); + + it('should reject non app-icon URL icon on update', async () => { + setupContextForWrite(createMockUserActor(1)); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await expect(crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + icon: 'https://example.com/webhook', + }, + })).rejects.toMatchObject({ + fields: { code: 'field_invalid', key: 'icon' }, + }); + }); + it('should emit app.rename event when name changes', async () => { setupContextForWrite(createMockUserActor(1)); diff --git a/src/backend/src/om/proptypes/__all__.js b/src/backend/src/om/proptypes/__all__.js index 5e94a783bd..27b997e97a 100644 --- a/src/backend/src/om/proptypes/__all__.js +++ b/src/backend/src/om/proptypes/__all__.js @@ -17,6 +17,7 @@ * along with this program. If not, see . */ const APIError = require('../../api/APIError'); +const config = require('../../config'); const { NodeUIDSelector, NodeInternalIDSelector, NodePathSelector } = require('../../filesystem/node/selectors'); const { is_valid_uuid4, is_valid_uuid } = require('../../helpers'); const validator = require('validator'); @@ -26,17 +27,79 @@ const FSNodeContext = require('../../filesystem/FSNodeContext'); const { Entity } = require('../entitystorage/Entity'); const NULL = Symbol('NULL'); const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/[^/?#]+\/\d+\/?$/; +const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; + +const getCanonicalAppIconBaseUrl = () => { + const candidate = [config.api_base_url, config.origin] + .find(value => typeof value === 'string' && value.trim()); + if ( ! candidate ) return null; + try { + return (new URL(candidate)).origin; + } catch { + return null; + } +}; const isAppIconEndpointPath = value => { if ( typeof value !== 'string' ) return false; + const trimmed = value.trim(); + if ( ! trimmed ) return false; try { - const pathname = new URL(value, 'http://localhost').pathname; + const pathname = new URL(trimmed, 'http://localhost').pathname; return APP_ICON_ENDPOINT_PATH_REGEX.test(pathname); } catch { return false; } }; +const getAllowedAppIconOrigins = () => { + const origins = new Set(); + for ( const candidate of [config.api_base_url, config.origin] ) { + if ( typeof candidate !== 'string' || !candidate ) continue; + try { + origins.add((new URL(candidate)).origin); + } catch { + // Ignore invalid config values. + } + } + return origins; +}; + +const isAllowedAppIconEndpointUrl = value => { + if ( ! isAppIconEndpointPath(value) ) return false; + + const trimmed = value.trim(); + const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); + if ( ! isAbsoluteUrl ) { + return true; + } + + try { + const parsed = new URL(trimmed, 'http://localhost'); + return getAllowedAppIconOrigins().has(parsed.origin); + } catch { + return false; + } +}; + +const migrateRelativeAppIconEndpointUrl = value => { + if ( typeof value !== 'string' ) return value; + const trimmed = value.trim(); + if ( ! isAppIconEndpointPath(trimmed) ) return value; + + const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); + if ( isAbsoluteUrl ) return trimmed; + + const baseUrl = getCanonicalAppIconBaseUrl(); + if ( ! baseUrl ) return trimmed; + + try { + return new URL(trimmed, `${baseUrl}/`).toString(); + } catch { + return trimmed; + } +}; + class OMTypeError extends Error { constructor ({ expected, got }) { const message = `expected ${expected}, got ${got}`; @@ -144,6 +207,9 @@ module.exports = { }, ['image-base64']: { from: 'string', + adapt (value) { + return migrateRelativeAppIconEndpointUrl(value); + }, validate (value) { if ( value.startsWith('data:image/') ) { // XSS characters @@ -154,19 +220,11 @@ module.exports = { return true; } - let valid = validator.isURL(value); - if ( ! valid ) { - valid = validator.isURL(value, { host_whitelist: ['localhost'] }); - } - if ( valid ) { - return true; - } - - if ( isAppIconEndpointPath(value) ) { + if ( isAllowedAppIconEndpointUrl(value) ) { return true; } - return new Error('icon must be base64 encoded or a valid URL'); + return new Error('icon must be base64 encoded or an app-icon endpoint URL'); }, }, url: { diff --git a/src/backend/src/om/proptypes/__all__.test.js b/src/backend/src/om/proptypes/__all__.test.js index a0b95f198b..5cdeeaad60 100644 --- a/src/backend/src/om/proptypes/__all__.test.js +++ b/src/backend/src/om/proptypes/__all__.test.js @@ -1,22 +1,33 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; const proptypes = require('./__all__'); +const config = require('../../config'); describe('OM image-base64 proptype', () => { const validateIcon = proptypes['image-base64'].validate; + const adaptIcon = proptypes['image-base64'].adapt; + + beforeAll(() => { + config.origin = 'https://puter.localhost'; + config.api_base_url = 'https://api.puter.localhost'; + }); it('accepts data URL icons', () => { expect(validateIcon('data:image/png;base64,abc123')).toBe(true); }); - it('accepts absolute URL icons', () => { - expect(validateIcon('https://example.com/icon.png')).toBe(true); + it('accepts absolute app-icon endpoint URLs', () => { + expect(validateIcon('https://api.puter.localhost/app-icon/app-uid-123/64')).toBe(true); }); it('accepts relative app-icon endpoint paths', () => { expect(validateIcon('/app-icon/app-uid-123/64')).toBe(true); }); + it('migrates relative app-icon endpoint paths to absolute URLs', () => { + expect(adaptIcon('/app-icon/app-uid-123/64')).toBe('https://api.puter.localhost/app-icon/app-uid-123/64'); + }); + it('accepts relative app-icon endpoint paths with query params', () => { expect(validateIcon('/app-icon/app-uid-123/64?v=123')).toBe(true); }); @@ -24,4 +35,8 @@ describe('OM image-base64 proptype', () => { it('rejects invalid icon values', () => { expect(validateIcon('not-an-icon')).toBeInstanceOf(Error); }); + + it('rejects foreign absolute app-icon endpoint URLs', () => { + expect(validateIcon('https://evil.example/app-icon/app-uid-123/64')).toBeInstanceOf(Error); + }); }); From 48fa269b6dbfee819028d35281b4ba5e988c579e Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Mon, 16 Feb 2026 12:01:57 -0800 Subject: [PATCH 2/2] fix: app icon origin size --- .../src/modules/apps/AppIconService.js | 86 ++++++----- .../src/modules/apps/AppIconService.test.js | 21 +++ .../src/modules/data-access/AppService.js | 98 +++++++++++-- .../modules/data-access/AppService.test.js | 48 ++++++- src/backend/src/om/proptypes/__all__.js | 134 ++++++++++++++---- src/backend/src/om/proptypes/__all__.test.js | 24 +++- 6 files changed, 333 insertions(+), 78 deletions(-) diff --git a/src/backend/src/modules/apps/AppIconService.js b/src/backend/src/modules/apps/AppIconService.js index 0ed8c42922..c73c71d020 100644 --- a/src/backend/src/modules/apps/AppIconService.js +++ b/src/backend/src/modules/apps/AppIconService.js @@ -34,6 +34,7 @@ import DEFAULT_APP_ICON from './default-app-icon.js'; const require = createRequire(import.meta.url); const ICON_SIZES = [16, 32, 64, 128, 256, 512]; +const DEFAULT_ICON_SIZE = 128; const LEGACY_ICON_FILENAME = ({ appUid, size }) => `${appUid}-${size}.png`; const ORIGINAL_ICON_FILENAME = ({ appUid }) => `${appUid}.png`; const REDIRECT_MAX_AGE_SIZE = 30 * 24 * 60 * 60; // 1 month @@ -64,42 +65,54 @@ export class AppIconService extends BaseService { /** * AppIconService listens to this event to register the - * endpoint /app-icon/:app_uid/:size which serves the - * app icon at the requested size. + * endpoints /app-icon/:app_uid and /app-icon/:app_uid/:size + * which serve the app icon at the requested size. */ async ['__on_install.routes'] (_, { app }) { - Endpoint({ - route: '/app-icon/:app_uid/:size', - methods: ['GET'], - handler: async (req, res) => { - // Validate parameters - let { app_uid: appUid, size } = req.params; - if ( ! ICON_SIZES.includes(Number(size)) ) { - res.status(400).send('Invalid size'); - return; - } - if ( ! appUid.startsWith('app-') ) { - appUid = `app-${appUid}`; - } + const handler = async (req, res) => { + // Validate parameters + let { app_uid: appUid, size } = req.params; + const resolvedSize = Number(size ?? DEFAULT_ICON_SIZE); + if ( ! ICON_SIZES.includes(resolvedSize) ) { + res.status(400).send('Invalid size'); + return; + } + if ( ! appUid.startsWith('app-') ) { + appUid = `app-${appUid}`; + } - const { - stream, - mime, - redirectUrl, - redirectCacheControl, - } = await this.#getIconStream({ appUid, size, allowRedirect: true }); + const { + stream, + mime, + redirectUrl, + redirectCacheControl, + } = await this.#getIconStream({ + appUid, + size: resolvedSize, + allowRedirect: true, + }); - if ( redirectUrl ) { - if ( redirectCacheControl ) { - res.set('Cache-Control', redirectCacheControl); - } - return res.redirect(302, redirectUrl); + if ( redirectUrl ) { + if ( redirectCacheControl ) { + res.set('Cache-Control', redirectCacheControl); } + return res.redirect(302, redirectUrl); + } - res.set('Content-Type', mime); - res.set('Cache-Control', 'public, max-age=3600'); - stream.pipe(res); - }, + res.set('Content-Type', mime); + res.set('Cache-Control', 'public, max-age=3600'); + stream.pipe(res); + }; + + Endpoint({ + route: '/app-icon/:app_uid', + methods: ['GET'], + handler, + }).attach(app); + Endpoint({ + route: '/app-icon/:app_uid/:size', + methods: ['GET'], + handler, }).attach(app); } @@ -131,7 +144,12 @@ export class AppIconService extends BaseService { return null; } - return `${apiBaseUrl}/app-icon/${normalizedAppUid}/${size}`; + const resolvedSize = Number(size ?? DEFAULT_ICON_SIZE); + if ( ! ICON_SIZES.includes(resolvedSize) ) { + return null; + } + + return `${apiBaseUrl}/app-icon/${normalizedAppUid}/${resolvedSize}`; } normalizeAppUid (appUid) { @@ -159,12 +177,14 @@ export class AppIconService extends BaseService { return null; } - const match = pathname.match(/^\/app-icon\/([^/]+)\/(\d+)\/?$/); + const match = pathname.match(/^\/app-icon\/([^/]+)(?:\/(\d+))?\/?$/); if ( ! match ) return null; + const size = Number(match[2] ?? DEFAULT_ICON_SIZE); + return { appUid: this.normalizeAppUid(match[1]), - size: Number(match[2]), + size, }; } diff --git a/src/backend/src/modules/apps/AppIconService.test.js b/src/backend/src/modules/apps/AppIconService.test.js index d625449211..e9f587c35c 100644 --- a/src/backend/src/modules/apps/AppIconService.test.js +++ b/src/backend/src/modules/apps/AppIconService.test.js @@ -33,6 +33,17 @@ describe('AppIconService', () => { expect(shouldRedirect).toBe(false); }); + + it('parses app-icon endpoint URLs without size as default size 128', () => { + const service = Object.create(AppIconService.prototype); + + const parsed = service.parseAppIconEndpointUrl('https://api.puter.localhost/app-icon/app-123'); + + expect(parsed).toEqual({ + appUid: 'app-123', + size: 128, + }); + }); }); describe('createAppIcons', () => { @@ -94,6 +105,16 @@ describe('AppIconService', () => { expect(result).toBe(`${config.api_base_url}/app-icon/app-abc/64`); }); + it('defaults to size 128 when size is not provided', () => { + const service = Object.create(AppIconService.prototype); + + const result = service.getAppIconPath({ + appUid: 'abc', + }); + + expect(result).toBe(`${config.api_base_url}/app-icon/app-abc/128`); + }); + it('iconifyApps rewrites icons to the legacy app-icon endpoint path', async () => { const service = Object.create(AppIconService.prototype); const apps = [ diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 58e7bd5b9e..b2c45d7e90 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -20,8 +20,11 @@ import { validate_url, } from './lib/validation.js'; -const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/[^/?#]+\/\d+\/?$/; +const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/([^/?#]+)(?:\/(\d+))?\/?$/; +const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/; +const APP_ICONS_SUBDOMAIN = 'puter-app-icons'; const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; +const isAbsoluteUrl = value => ABSOLUTE_URL_REGEX.test(value) || value.startsWith('//'); const getCanonicalAppIconBaseUrl = () => { const candidate = [config.api_base_url, config.origin] @@ -47,26 +50,47 @@ const getAllowedAppIconOrigins = () => { return origins; }; -const isAppIconEndpointPath = (value) => { - if ( typeof value !== 'string' ) return false; +const getAllowedLegacyAppIconHostnames = () => { + const hostnames = new Set(); + const domains = [config.static_hosting_domain, config.static_hosting_domain_alt]; + for ( const domain of domains ) { + if ( typeof domain !== 'string' || !domain.trim() ) continue; + hostnames.add(`${APP_ICONS_SUBDOMAIN}.${domain.trim().toLowerCase()}`); + } + return hostnames; +}; + +const normalizeAppUid = appUid => ( + typeof appUid === 'string' && appUid.startsWith('app-') + ? appUid + : `app-${appUid}` +); + +const parseAppIconEndpointPath = (value) => { + if ( typeof value !== 'string' ) return null; const trimmed = value.trim(); - if ( ! trimmed ) return false; + if ( ! trimmed ) return null; try { const parsed = new URL(trimmed, 'http://localhost'); - const pathname = parsed.pathname; - return APP_ICON_ENDPOINT_PATH_REGEX.test(pathname); + const match = parsed.pathname.match(APP_ICON_ENDPOINT_PATH_REGEX); + if ( ! match ) return null; + + return { + appUid: normalizeAppUid(match[1]), + }; } catch { - return false; + return null; } }; +const isAppIconEndpointPath = value => !!parseAppIconEndpointPath(value); + const isAllowedAppIconEndpointUrl = value => { if ( ! isAppIconEndpointPath(value) ) return false; const trimmed = value.trim(); - const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); - if ( ! isAbsoluteUrl ) { + if ( ! isAbsoluteUrl(trimmed) ) { return true; } @@ -78,21 +102,65 @@ const isAllowedAppIconEndpointUrl = value => { } }; +const parseLegacyHostedAppIconToEndpointPath = value => { + if ( typeof value !== 'string' ) return null; + const trimmed = value.trim(); + if ( !trimmed || trimmed.startsWith('data:') ) return null; + + let parsed; + try { + parsed = new URL(trimmed, 'http://localhost'); + } catch { + return null; + } + + const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); + if ( isAbsoluteUrl ) { + const allowedHostnames = getAllowedLegacyAppIconHostnames(); + const hostname = parsed.hostname.toLowerCase(); + if ( ! allowedHostnames.has(hostname) ) { + return null; + } + } + + const match = parsed.pathname.match(LEGACY_APP_ICON_FILE_PATH_REGEX); + if ( ! match ) return null; + + const appUid = normalizeAppUid(match[1]); + return `/app-icon/${appUid}`; +}; + const migrateRelativeAppIconEndpointUrl = value => { if ( typeof value !== 'string' ) return value; const trimmed = value.trim(); - if ( ! isAppIconEndpointPath(trimmed) ) return value; + if ( ! trimmed ) return value; - const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); - if ( isAbsoluteUrl ) return trimmed; + let canonicalEndpointPath = null; + const endpointPath = parseAppIconEndpointPath(trimmed); + if ( endpointPath ) { + if ( isAbsoluteUrl(trimmed) ) { + try { + const parsed = new URL(trimmed, 'http://localhost'); + if ( ! getAllowedAppIconOrigins().has(parsed.origin) ) { + return value; + } + } catch { + return value; + } + } + canonicalEndpointPath = `/app-icon/${endpointPath.appUid}`; + } else { + canonicalEndpointPath = parseLegacyHostedAppIconToEndpointPath(trimmed); + } + if ( ! canonicalEndpointPath ) return value; const baseUrl = getCanonicalAppIconBaseUrl(); - if ( ! baseUrl ) return trimmed; + if ( ! baseUrl ) return canonicalEndpointPath; try { - return new URL(trimmed, `${baseUrl}/`).toString(); + return new URL(canonicalEndpointPath, `${baseUrl}/`).toString(); } catch { - return trimmed; + return canonicalEndpointPath; } }; diff --git a/src/backend/src/modules/data-access/AppService.test.js b/src/backend/src/modules/data-access/AppService.test.js index 6338bb1a18..6763213257 100644 --- a/src/backend/src/modules/data-access/AppService.test.js +++ b/src/backend/src/modules/data-access/AppService.test.js @@ -848,11 +848,32 @@ describe('AppService', () => { expect(mockEventService.emit).toHaveBeenCalledWith( 'app.new-icon', expect.objectContaining({ - data_url: 'https://api.puter.localhost/app-icon/app-uid-123/64', + data_url: 'https://api.puter.localhost/app-icon/app-uid-123', })); expect(validate_url).toHaveBeenCalledWith('https://example.com', expect.objectContaining({ key: 'index_url' })); }); + it('should migrate legacy app-icons host URL to app-icon endpoint URL on create', async () => { + setupContextForWrite(createMockUserActor(1)); + mockDb.read.mockResolvedValue([createMockAppRow()]); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.create.call(appService, { + object: { + name: 'test-app', + title: 'Test', + index_url: 'https://example.com', + icon: 'https://puter-app-icons.puter.site/app-uid-123-64.png', + }, + }); + + expect(mockEventService.emit).toHaveBeenCalledWith( + 'app.new-icon', + expect.objectContaining({ + data_url: 'https://api.puter.localhost/app-icon/app-uid-123', + })); + }); + it('should allow absolute app-icon endpoint URL on API origin', async () => { setupContextForWrite(createMockUserActor(1)); mockDb.read.mockResolvedValue([createMockAppRow()]); @@ -870,7 +891,7 @@ describe('AppService', () => { expect(mockEventService.emit).toHaveBeenCalledWith( 'app.new-icon', expect.objectContaining({ - data_url: 'https://api.puter.localhost/app-icon/app-uid-123/64', + data_url: 'https://api.puter.localhost/app-icon/app-uid-123', })); }); @@ -1180,7 +1201,26 @@ describe('AppService', () => { 'app.new-icon', expect.objectContaining({ app_uid: 'app-uid-123', - data_url: 'https://api.puter.localhost/app-icon/app-uid-123/64', + data_url: 'https://api.puter.localhost/app-icon/app-uid-123', + })); + }); + + it('should migrate legacy app-icons host URL to app-icon endpoint URL on update', async () => { + setupContextForWrite(createMockUserActor(1)); + + const crudQ = AppService.IMPLEMENTS['crud-q']; + await crudQ.update.call(appService, { + object: { + uid: 'app-uid-123', + icon: 'https://puter-app-icons.puter.site/app-uid-123-64.png', + }, + }); + + expect(mockEventService.emit).toHaveBeenCalledWith( + 'app.new-icon', + expect.objectContaining({ + app_uid: 'app-uid-123', + data_url: 'https://api.puter.localhost/app-icon/app-uid-123', })); }); @@ -1199,7 +1239,7 @@ describe('AppService', () => { 'app.new-icon', expect.objectContaining({ app_uid: 'app-uid-123', - data_url: 'https://api.puter.localhost/app-icon/app-uid-123/64', + data_url: 'https://api.puter.localhost/app-icon/app-uid-123', })); }); diff --git a/src/backend/src/om/proptypes/__all__.js b/src/backend/src/om/proptypes/__all__.js index 27b997e97a..72c4924d6d 100644 --- a/src/backend/src/om/proptypes/__all__.js +++ b/src/backend/src/om/proptypes/__all__.js @@ -26,8 +26,11 @@ const { is_valid_path } = require('../../filesystem/validation'); const FSNodeContext = require('../../filesystem/FSNodeContext'); const { Entity } = require('../entitystorage/Entity'); const NULL = Symbol('NULL'); -const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/[^/?#]+\/\d+\/?$/; +const APP_ICON_ENDPOINT_PATH_REGEX = /^\/app-icon\/([^/?#]+)(?:\/(\d+))?\/?$/; +const LEGACY_APP_ICON_FILE_PATH_REGEX = /^\/(app-[^/?#]+?)(?:-(\d+))?\.png$/; +const APP_ICONS_SUBDOMAIN = 'puter-app-icons'; const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; +const isAbsoluteUrl = value => ABSOLUTE_URL_REGEX.test(value) || value.startsWith('//'); const getCanonicalAppIconBaseUrl = () => { const candidate = [config.api_base_url, config.origin] @@ -40,18 +43,29 @@ const getCanonicalAppIconBaseUrl = () => { } }; -const isAppIconEndpointPath = value => { - if ( typeof value !== 'string' ) return false; +const normalizeAppUid = appUid => ( + typeof appUid === 'string' && appUid.startsWith('app-') + ? appUid + : `app-${appUid}` +); + +const parseAppIconEndpointPath = value => { + if ( typeof value !== 'string' ) return null; const trimmed = value.trim(); - if ( ! trimmed ) return false; + if ( ! trimmed ) return null; try { - const pathname = new URL(trimmed, 'http://localhost').pathname; - return APP_ICON_ENDPOINT_PATH_REGEX.test(pathname); + const match = new URL(trimmed, 'http://localhost').pathname.match(APP_ICON_ENDPOINT_PATH_REGEX); + if ( ! match ) return null; + return { + appUid: normalizeAppUid(match[1]), + }; } catch { - return false; + return null; } }; +const isAppIconEndpointPath = value => !!parseAppIconEndpointPath(value); + const getAllowedAppIconOrigins = () => { const origins = new Set(); for ( const candidate of [config.api_base_url, config.origin] ) { @@ -65,12 +79,21 @@ const getAllowedAppIconOrigins = () => { return origins; }; +const getAllowedLegacyAppIconHostnames = () => { + const hostnames = new Set(); + const domains = [config.static_hosting_domain, config.static_hosting_domain_alt]; + for ( const domain of domains ) { + if ( typeof domain !== 'string' || !domain.trim() ) continue; + hostnames.add(`${APP_ICONS_SUBDOMAIN}.${domain.trim().toLowerCase()}`); + } + return hostnames; +}; + const isAllowedAppIconEndpointUrl = value => { if ( ! isAppIconEndpointPath(value) ) return false; const trimmed = value.trim(); - const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); - if ( ! isAbsoluteUrl ) { + if ( ! isAbsoluteUrl(trimmed) ) { return true; } @@ -82,21 +105,65 @@ const isAllowedAppIconEndpointUrl = value => { } }; +const parseLegacyHostedAppIconToEndpointPath = value => { + if ( typeof value !== 'string' ) return null; + const trimmed = value.trim(); + if ( !trimmed || trimmed.startsWith('data:') ) return null; + + let parsed; + try { + parsed = new URL(trimmed, 'http://localhost'); + } catch { + return null; + } + + const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); + if ( isAbsoluteUrl ) { + const allowedHostnames = getAllowedLegacyAppIconHostnames(); + const hostname = parsed.hostname.toLowerCase(); + if ( ! allowedHostnames.has(hostname) ) { + return null; + } + } + + const match = parsed.pathname.match(LEGACY_APP_ICON_FILE_PATH_REGEX); + if ( ! match ) return null; + + const appUid = normalizeAppUid(match[1]); + return `/app-icon/${appUid}`; +}; + const migrateRelativeAppIconEndpointUrl = value => { if ( typeof value !== 'string' ) return value; const trimmed = value.trim(); - if ( ! isAppIconEndpointPath(trimmed) ) return value; + if ( ! trimmed ) return value; - const isAbsoluteUrl = ABSOLUTE_URL_REGEX.test(trimmed) || trimmed.startsWith('//'); - if ( isAbsoluteUrl ) return trimmed; + let canonicalEndpointPath = null; + const endpointPath = parseAppIconEndpointPath(trimmed); + if ( endpointPath ) { + if ( isAbsoluteUrl(trimmed) ) { + try { + const parsed = new URL(trimmed, 'http://localhost'); + if ( ! getAllowedAppIconOrigins().has(parsed.origin) ) { + return value; + } + } catch { + return value; + } + } + canonicalEndpointPath = `/app-icon/${endpointPath.appUid}`; + } else { + canonicalEndpointPath = parseLegacyHostedAppIconToEndpointPath(trimmed); + } + if ( ! canonicalEndpointPath ) return value; const baseUrl = getCanonicalAppIconBaseUrl(); - if ( ! baseUrl ) return trimmed; + if ( ! baseUrl ) return canonicalEndpointPath; try { - return new URL(trimmed, `${baseUrl}/`).toString(); + return new URL(canonicalEndpointPath, `${baseUrl}/`).toString(); } catch { - return trimmed; + return canonicalEndpointPath; } }; @@ -141,13 +208,13 @@ module.exports = { if ( typeof value !== 'string' ) { return new OMTypeError({ expected: 'string', got: typeof value }); } - if ( descriptor.hasOwnProperty('maxlen') && value.length > descriptor.maxlen ) { + if ( Object.prototype.hasOwnProperty.call(descriptor, 'maxlen') && value.length > descriptor.maxlen ) { throw APIError.create('field_too_long', null, { key: name, max_length: descriptor.maxlen }); } - if ( descriptor.hasOwnProperty('minlen') && value.length > descriptor.minlen ) { + if ( Object.prototype.hasOwnProperty.call(descriptor, 'minlen') && value.length > descriptor.minlen ) { throw APIError.create('field_too_short', null, { key: name, min_length: descriptor.maxlen }); } - if ( descriptor.hasOwnProperty('regex') && !value.match(descriptor.regex) ) { + if ( Object.prototype.hasOwnProperty.call(descriptor, 'regex') && !value.match(descriptor.regex) ) { return new Error(`string does not match regex ${descriptor.regex}`); } return true; @@ -159,13 +226,13 @@ module.exports = { if ( ! Array.isArray(value) ) { return new OMTypeError({ expected: 'array', got: typeof value }); } - if ( descriptor.hasOwnProperty('maxlen') && value.length > descriptor.maxlen ) { + if ( Object.prototype.hasOwnProperty.call(descriptor, 'maxlen') && value.length > descriptor.maxlen ) { throw APIError.create('field_too_long', null, { key: name, max_length: descriptor.maxlen }); } - if ( descriptor.hasOwnProperty('minlen') && value.length > descriptor.minlen ) { + if ( Object.prototype.hasOwnProperty.call(descriptor, 'minlen') && value.length > descriptor.minlen ) { throw APIError.create('field_too_short', null, { key: name, min_length: descriptor.maxlen }); } - if ( descriptor.hasOwnProperty('mod') && value.length % descriptor.mod !== 0 ) { + if ( Object.prototype.hasOwnProperty.call(descriptor, 'mod') && value.length % descriptor.mod !== 0 ) { throw APIError.create('field_invalid', null, { key: name, mod: descriptor.mod }); } return true; @@ -207,20 +274,37 @@ module.exports = { }, ['image-base64']: { from: 'string', + is_set (value) { + return typeof value === 'string' && value.trim().length > 0; + }, adapt (value) { + if ( value === NULL ) return null; + if ( value === undefined || value === null ) return ''; + if ( typeof value !== 'string' ) { + throw new OMTypeError({ expected: 'string', got: typeof value }); + } return migrateRelativeAppIconEndpointUrl(value); }, validate (value) { - if ( value.startsWith('data:image/') ) { + if ( typeof value !== 'string' ) { + return new OMTypeError({ expected: 'string', got: typeof value }); + } + + const trimmed = value.trim(); + if ( ! trimmed ) { + return true; + } + + if ( trimmed.startsWith('data:image/') ) { // XSS characters const chars = ['<', '>', '&', '"', "'", '`']; - if ( chars.some(char => value.includes(char)) ) { + if ( chars.some(char => trimmed.includes(char)) ) { return new Error('icon is not an image'); } return true; } - if ( isAllowedAppIconEndpointUrl(value) ) { + if ( isAllowedAppIconEndpointUrl(trimmed) ) { return true; } @@ -329,7 +413,7 @@ module.exports = { const node = await svc_fs.node(selector); return node; }, - async validate (value, { name, descriptor }) { + async validate (value, { descriptor }) { if ( value === null ) return; const actor = Context.get('actor'); const permission = descriptor.fs_permission ?? 'see'; diff --git a/src/backend/src/om/proptypes/__all__.test.js b/src/backend/src/om/proptypes/__all__.test.js index 5cdeeaad60..f900940bcb 100644 --- a/src/backend/src/om/proptypes/__all__.test.js +++ b/src/backend/src/om/proptypes/__all__.test.js @@ -10,6 +10,7 @@ describe('OM image-base64 proptype', () => { beforeAll(() => { config.origin = 'https://puter.localhost'; config.api_base_url = 'https://api.puter.localhost'; + config.static_hosting_domain = 'puter.site'; }); it('accepts data URL icons', () => { @@ -20,12 +21,33 @@ describe('OM image-base64 proptype', () => { expect(validateIcon('https://api.puter.localhost/app-icon/app-uid-123/64')).toBe(true); }); + it('accepts absolute app-icon endpoint URLs without size', () => { + expect(validateIcon('https://api.puter.localhost/app-icon/app-uid-123')).toBe(true); + }); + it('accepts relative app-icon endpoint paths', () => { expect(validateIcon('/app-icon/app-uid-123/64')).toBe(true); }); + it('accepts relative app-icon endpoint paths without size', () => { + expect(validateIcon('/app-icon/app-uid-123')).toBe(true); + }); + it('migrates relative app-icon endpoint paths to absolute URLs', () => { - expect(adaptIcon('/app-icon/app-uid-123/64')).toBe('https://api.puter.localhost/app-icon/app-uid-123/64'); + expect(adaptIcon('/app-icon/app-uid-123/64')).toBe('https://api.puter.localhost/app-icon/app-uid-123'); + }); + + it('migrates legacy app-icons host URLs to absolute app-icon endpoint URLs', () => { + expect(adaptIcon('https://puter-app-icons.puter.site/app-uid-123-64.png')) + .toBe('https://api.puter.localhost/app-icon/app-uid-123'); + }); + + it('treats empty icon as valid', () => { + expect(validateIcon('')).toBe(true); + }); + + it('adapts null icon to empty string', () => { + expect(adaptIcon(null)).toBe(''); }); it('accepts relative app-icon endpoint paths with query params', () => {