diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts index 311e8a9c9..cd8e19f1d 100644 --- a/src/backend/core/http/middleware/puterSite.test.ts +++ b/src/backend/core/http/middleware/puterSite.test.ts @@ -1523,6 +1523,86 @@ describe('createPuterSiteMiddleware — __workers/ is never served', () => { expect(Buffer.isBuffer(piped)).toBe(true); expect(piped!.equals(shell)).toBe(true); }); + + // SQLite compares paths bytewise; MySQL's utf8mb4_unicode_ci folds + // accents, fullwidth forms and zero-width chars. Emulate that fold. + const withCollationAlias = async ( + alias: string, + real: string, + fn: () => Promise, + ) => { + const store = server.stores.fsEntry; + const original = store.getEntryByPath.bind(store); + const spy = vi + .spyOn(store, 'getEntryByPath') + .mockImplementation((path, options) => + original( + path.startsWith(alias) + ? real + path.slice(alias.length) + : path, + options, + ), + ); + try { + await fn(); + } finally { + spy.mockRestore(); + } + }; + + it('404s a collation alias of __workers/ that the database resolves to the real file', async () => { + const { homePath, sub } = await setupSiteWithWorker(); + await withCollationAlias( + `${homePath}/__wörkers/matchmaking.worker.js`, + `${homePath}/__workers/matchmaking.worker.js`, + async () => { + const out = await request( + sub, + '/__w%C3%B6rkers/matchmaking.worker.js', + ); + expect(out.statusCode).toBe(404); + expect(String(out.body)).not.toContain('secret'); + }, + ); + }); + + it('404s a collation alias of a folder whose index.html sits in __workers/', async () => { + const { owner, homePath, sub } = await setupSiteWithWorker(); + await writeFile( + owner.id, + `${homePath}/__workers/index.html`, + Buffer.from('listing'), + 'text/html', + ); + await withCollationAlias( + `${homePath}/__wörkers`, + `${homePath}/__workers`, + async () => { + const out = await request(sub, '/__w%C3%B6rkers'); + expect(out.statusCode).toBe(404); + expect(String(out.body)).not.toContain('listing'); + }, + ); + }); + + it('404s a collation alias of .puter_site_config', async () => { + const { owner, homePath, sub } = await setupSiteWithWorker(); + await writeFile( + owner.id, + `${homePath}/.puter_site_config`, + Buffer.from(JSON.stringify({ errors: {}, marker: 'cfg-body' })), + 'application/json', + ); + await withCollationAlias( + `${homePath}/.püter_site_config`, + `${homePath}/.puter_site_config`, + async () => { + const out = await request(sub, '/.p%C3%BCter_site_config'); + expect(out.statusCode).toBe(404); + expect(String(out.body)).not.toContain('cfg-body'); + }, + ); + }); }); describe('createPuterSiteMiddleware — hosting CSP', () => { diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 4ad331893..4c1c082ee 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -94,6 +94,20 @@ const isWorkersSourcePath = (urlPath: string): boolean => .split('/') .some((segment) => segment.toLowerCase() === WORKERS_FOLDER); +/** + * Checks the stored path of a looked-up entry. The URL-level guards are not + * enough on MySQL: `fsentries.path` is utf8mb4_unicode_ci, so `__wörkers`, + * fullwidth or zero-width spellings resolve to the real `__workers` row. + */ +const isServableEntryPath = ( + entryPath: string | null | undefined, + rootPath: string, +): boolean => { + if (!entryPath?.startsWith(`${rootPath}/`)) return false; + const relPath = entryPath.slice(rootPath.length); + return !isWorkersSourcePath(relPath) && !isSiteConfigPath(relPath); +}; + /** Inside some owner's top-level Trash, which is where Delete puts things. */ const isTrashedPath = (path: string): boolean => /^\/[^/]+\/Trash(\/|$)/u.test(path); @@ -620,6 +634,9 @@ export const createPuterSiteMiddleware = ( pathPosix.join(filePath, 'index.html'), ); } + if (entry && !isServableEntryPath(entry.path, rootPath)) { + entry = null; + } // Custom error page fallback. On a 404 we consult the site config // for a rule and, if one resolves to an existing file, serve that @@ -640,7 +657,11 @@ export const createPuterSiteMiddleware = ( const candidate = await layers.stores.fsEntry.getEntryByPath( errorTarget.absPath, ); - if (candidate && !candidate.isDir) { + if ( + candidate && + !candidate.isDir && + isServableEntryPath(candidate.path, rootPath) + ) { entry = candidate; statusOverride = errorTarget.status; } diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 222e5f671..7a1d944bd 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -1867,12 +1867,16 @@ export class AuthService extends PuterService { /** * Materialize the `kind='asset'` session row that the cookie's - * `session_uuid` claim points at. Parented to the web session so a logout - * cascade kills every asset cookie minted under it. Both fields are `null` - * only when the caller didn't supply a web session at all — the cookie - * still mints unparented and without an `auth_id` claim (matches v1 - * behavior for access-token-minted cookies that aren't tied to an - * interactive session). + * `session_uuid` claim points at. Parented to the minting session so + * revoking it kills the cookie. The cascade is one level deep, so a cookie + * minted from a godmode token (itself under a web session) outlives logout + * of that web session; accepted, as app tokens outlive it too and the + * cookie only serves one app's files after an entitlement check. + * + * Both fields are `null` only when the caller didn't supply a session at + * all — the cookie still mints unparented and without an `auth_id` claim + * (matches v1 behavior for access-token-minted cookies that aren't tied to + * an interactive session). * * If the caller DID supply a `webSessionUuid` but the lookup misses (row * revoked / expired between mint request and this lookup), throw — @@ -1907,9 +1911,10 @@ export class AuthService extends PuterService { /** * Verify a hosted-asset cookie of `kind` against what the request expects, * and that the session it was minted under still exists and isn't revoked: - * `getByUuid` filters on `revoked_at IS NULL`, so a logout cascade - * invalidates every cookie minted under that web session. A cookie minted - * without a session (from an access-token actor) has nothing to bind. + * `getByUuid` filters on `revoked_at IS NULL`, so revoking the minting + * session invalidates the cookie. Only the leaf row is checked; see + * `#mintAssetSessionContext` for the godmode case. A cookie minted without + * a session (from an access-token actor) has nothing to bind. */ async verifyHostedAssetToken( kind: HostedAssetKind, diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index c4cf10bed..79a31de08 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -4533,6 +4533,14 @@ describe('FSService move', () => { }), ); expect(intoItself.legacyCode).toBe('cannot_move_directory_into_itself'); + + const ontoItself = await caught(() => + fs.move(user.userId, { + source: dir, + destinationParent: dir, + }), + ); + expect(ontoItself.legacyCode).toBe('cannot_move_directory_into_itself'); }); it('handles a destination collision by conflict, overwrite or dedupe', async () => { diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 2c5742eb1..8a9d32e33 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -4901,7 +4901,8 @@ export class FSService extends PuterService { } if ( source.isDir && - destinationParent.path.startsWith(`${source.path}/`) + (destinationParent.uuid === source.uuid || + destinationParent.path.startsWith(`${source.path}/`)) ) { throw new HttpError(400, 'Cannot move a directory into itself', { legacyCode: 'cannot_move_directory_into_itself', diff --git a/src/backend/services/socket/SocketService.ts b/src/backend/services/socket/SocketService.ts index cf62b1830..bfe69db21 100644 --- a/src/backend/services/socket/SocketService.ts +++ b/src/backend/services/socket/SocketService.ts @@ -399,11 +399,7 @@ export class SocketService extends PuterService { const base = this.#authOptions(); const id = actor?.user?.id; if (typeof id !== 'number' || actor?.user?.otp_enabled) return base; - const seat = await ( - this.stores.team as unknown as { - getOrgSeat?: (n: number) => Promise<{ require_2fa?: number }>; - } - ) + const seat = await this.stores.team ?.getOrgSeat?.(id) .catch((): null => null); return { ...base, teamRequires2fa: Number(seat?.require_2fa) === 1 }; @@ -423,7 +419,8 @@ export class SocketService extends PuterService { // `{ auth: { ... } }`, not the query string. puter-js uses // `io(url, { auth: { auth_token } })`. const handshakeAuth = socket.handshake.auth as - Record | undefined; + | Record + | undefined; const tokenRaw = typeof handshakeAuth?.auth_token === 'string' ? handshakeAuth.auth_token @@ -932,7 +929,8 @@ export class SocketService extends PuterService { // their next poll of /cache/last-change-timestamp. const originalSocketId = ( data.response as - { original_client_socket_id?: string } | undefined + | { original_client_socket_id?: string } + | undefined )?.original_client_socket_id; await this.send({ room: userId }, 'cache.updated', { timestamp, @@ -946,7 +944,9 @@ export class SocketService extends PuterService { #handleUploadProgress(data: UploadProgressPayload): void { const meta = data.meta ?? {}; const userId = (meta.user_id ?? meta.userId) as - number | string | undefined; + | number + | string + | undefined; if (!userId) { console.warn('[socket] upload-progress missing user_id', { meta }); return; diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index 0643975a3..cd093fef7 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -152,7 +152,10 @@ const RESERVED_HANDLES = new Set([ ]); export type HandleRejection = - 'too_short' | 'too_long' | 'malformed' | 'reserved'; + | 'too_short' + | 'too_long' + | 'malformed' + | 'reserved'; /** Trimmed and capped, so the same name is accepted on every engine. */ export const normalizeTeamName = (name: string): string => { diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index 7b774db83..5ef3385a8 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -505,49 +505,64 @@ const ipc_listener = async (event, handled) => { // app-side promise only settles when a message with its id comes back // — so a rejected call is answered anyway rather than left hanging // forever. Logged here because that is the only place it is visible. - puter.kv.set({ - key: event.data.key, - value: event.data.value, - app_uid: app_uuid, - }).catch(err => console.warn('kv.setItem failed for app:', err)).then(() => { - // send confirmation to requester window - target_iframe.contentWindow.postMessage({ - original_msg_id: msg_id, - }, '*'); - }); + // These run on the user's session, so without an explicit app namespace + // they would land in the desktop's own account-wide settings. + if ( app_uuid ) { + try { + await puter.kv.set({ + key: event.data.key, + value: event.data.value, + optConfig: { appUuid: app_uuid }, + }); + } catch ( err ) { + console.warn('kv.setItem failed for app:', err); + } + } + // send confirmation to requester window + target_iframe.contentWindow.postMessage({ + original_msg_id: msg_id, + }, '*'); } //-------------------------------------------------------- // getItem //-------------------------------------------------------- else if ( event.data.msg === 'getItem' && event.data.key ) { - puter.kv.get({ - key: event.data.key, - app_uid: app_uuid, - }).catch(err => { - console.warn('kv.getItem failed for app:', err); - return null; - }).then((result) => { - // send confirmation to requester window - target_iframe.contentWindow.postMessage({ - original_msg_id: msg_id, - msg: 'getItemSucceeded', - value: result ?? null, - }, '*'); - }); + let result = null; + if ( app_uuid ) { + try { + result = await puter.kv.get({ + key: event.data.key, + optConfig: { appUuid: app_uuid }, + }); + } catch ( err ) { + console.warn('kv.getItem failed for app:', err); + } + } + // send confirmation to requester window + target_iframe.contentWindow.postMessage({ + original_msg_id: msg_id, + msg: 'getItemSucceeded', + value: result ?? null, + }, '*'); } //-------------------------------------------------------- // removeItem //-------------------------------------------------------- else if ( event.data.msg === 'removeItem' && event.data.key ) { - puter.kv.del({ - key: event.data.key, - app_uid: app_uuid, - }).catch(err => console.warn('kv.removeItem failed for app:', err)).then(() => { - // send confirmation to requester window - target_iframe.contentWindow.postMessage({ - original_msg_id: msg_id, - }, '*'); - }); + if ( app_uuid ) { + try { + await puter.kv.del({ + key: event.data.key, + optConfig: { appUuid: app_uuid }, + }); + } catch ( err ) { + console.warn('kv.removeItem failed for app:', err); + } + } + // send confirmation to requester window + target_iframe.contentWindow.postMessage({ + original_msg_id: msg_id, + }, '*'); } //-------------------------------------------------------- // showOpenFilePicker @@ -1522,62 +1537,6 @@ const ipc_listener = async (event, handled) => { }, '*'); $(target_iframe).get(0).focus({ preventScroll: true }); } - //-------------------------------------------------------- - // setWallpaper - //-------------------------------------------------------- - else if ( event.data.msg === 'setWallpaper' ) { - // Auth - if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) ) - { - return; - } - - // No options? - if ( ! event.data.options ) - { - event.data.options = {}; - } - - // /set-desktop-bg - try { - await $.ajax({ - url: `${window.api_origin }/set-desktop-bg`, - type: 'POST', - data: JSON.stringify({ - url: event.data.readURL, - fit: event.data.options.fit ?? 'cover', - color: event.data.options.color, - }), - async: true, - contentType: 'application/json', - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, - statusCode: { - 401: function (xhr) { - window.handle401(xhr); - }, - }, - }); - - // Set wallpaper - window.set_desktop_background({ - url: event.data.readURL, - fit: event.data.options.fit ?? 'cover', - color: event.data.options.color, - }); - - // Send success to app - target_iframe.contentWindow.postMessage({ - msg: 'wallpaperSet', - original_msg_id: msg_id, - }, '*'); - $(target_iframe).get(0).focus({ preventScroll: true }); - } catch ( err ) { - console.error(err); - } - } - //-------------------------------------------------------- // showSaveFilePicker //-------------------------------------------------------- diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 232cf9119..b2f0ee605 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -293,11 +293,12 @@ const TabFiles = { puter.kv.get('sort_direction').catch(() => null), puter.kv.get('column_widths').catch(() => null), ]); - this.currentView = savedView || 'list'; + // Unknown stored values fall back to the defaults; sortColumn ends up in a selector. + this.currentView = ['list', 'grid', 'grid-sm'].includes(savedView) ? savedView : 'list'; // Sorting state - this.sortColumn = savedSortColumn || 'name'; - this.sortDirection = savedSortDirection || 'asc'; + this.sortColumn = ['name', 'size', 'modified'].includes(savedSortColumn) ? savedSortColumn : 'name'; + this.sortDirection = savedSortDirection === 'desc' ? 'desc' : 'asc'; // Column widths state (for resizing). A corrupt saved value falls back // to the defaults instead of taking the whole tab down with it. diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 50fba4b45..902f68627 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -722,7 +722,15 @@ async function UIDesktop (options) { // load window sidebar items from KV puter.kv.get('sidebar_items').then(async (val) => { - window.sidebar_items = val; + // UIWindow parses this and reads `item.path` while building every + // explorer window, so a malformed value is dropped for the defaults. + let items = null; + try { + items = JSON.parse(val); + } catch { /* keep the defaults */ } + if ( Array.isArray(items) && items.every(item => typeof item?.path === 'string') ) { + window.sidebar_items = val; + } // Catch on the chain rather than the read, so a failure leaves whatever is // already there instead of overwriting it with a fallback. }).catch(err => console.warn('Could not load sidebar_items:', err)); diff --git a/src/gui/src/UI/UITaskbar.js b/src/gui/src/UI/UITaskbar.js index 637dfcfda..a0091c404 100644 --- a/src/gui/src/UI/UITaskbar.js +++ b/src/gui/src/UI/UITaskbar.js @@ -24,6 +24,8 @@ import launch_app from '../helpers/launchApp.js'; import UIContextMenu from './UIContextMenu.js'; import { appIconAttrs, appIconSrc } from '../helpers/appIcon.js'; +const TASKBAR_POSITIONS = ['left', 'bottom', 'right']; + async function UITaskbar (options) { window.global_element_id++; @@ -40,7 +42,8 @@ async function UITaskbar (options) { taskbar_position = await puter.kv.get('taskbar_position').catch(() => null); // if this is not first visit, set taskbar position to bottom since it's from a user that // used puter before customizing taskbar position was added and the taskbar position was set to bottom - if ( ! taskbar_position ) { + // Unknown values are discarded too: this ends up in markup and stored values may be poisoned. + if ( ! TASKBAR_POSITIONS.includes(taskbar_position) ) { taskbar_position = 'bottom'; // default position puter.kv.set('taskbar_position', taskbar_position).catch(err => console.warn('Could not save taskbar_position:', err)); } @@ -501,10 +504,7 @@ window.update_taskbar_position = async function (new_position) { return; } - // Valid positions - const valid_positions = ['left', 'bottom', 'right']; - - if ( ! valid_positions.includes(new_position) ) { + if ( ! TASKBAR_POSITIONS.includes(new_position) ) { return; } diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index f11285600..c23c94df1 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -708,7 +708,10 @@ window.mutate_user_preferences = function (user_preferences_delta) { window.update_user_preferences = function (user_preferences) { window.user_preferences = user_preferences; localStorage.setItem('user_preferences', JSON.stringify(user_preferences)); - const language = user_preferences.language ?? 'en'; + // The stored value can be anything, and i18n/Intl throw on some bad locales. + const language = window.listSupportedLanguages().some(lang => lang.code === user_preferences.language) + ? user_preferences.language + : 'en'; window.locale = language; // Broadcast locale change to apps diff --git a/src/gui/src/helpers/shareRecents.js b/src/gui/src/helpers/shareRecents.js index e87e49717..1ff114843 100644 --- a/src/gui/src/helpers/shareRecents.js +++ b/src/gui/src/helpers/shareRecents.js @@ -60,7 +60,9 @@ export const sanitize_recents = (value) => { out.push({ kind: entry.kind, id, - name: typeof entry.name === 'string' && entry.name.trim() !== '' + // A person is shown by the id the share goes to, so a stored name + // can't present one recipient as another. + name: entry.kind === 'team' && typeof entry.name === 'string' && entry.name.trim() !== '' ? entry.name : id, }); diff --git a/src/gui/src/helpers/shareRecents.test.js b/src/gui/src/helpers/shareRecents.test.js index 629f5f068..60c01523e 100644 --- a/src/gui/src/helpers/shareRecents.test.js +++ b/src/gui/src/helpers/shareRecents.test.js @@ -37,6 +37,18 @@ describe('sanitize_recents', () => { .toEqual([{ kind: 'user', id: 'bob', name: 'bob' }]); }); + it('shows a person or invite by their id, whatever name was stored', () => { + expect(sanitize_recents([ + { kind: 'user', id: 'bob', name: 'alice' }, + { kind: 'invite', id: 'ann@example.com', name: 'carol@example.com' }, + { kind: 'team', id: 'team-1', name: 'Design' }, + ])).toEqual([ + BOB, + ANN, + { kind: 'team', id: 'team-1', name: 'Design' }, + ]); + }); + it('keeps one entry per recipient, the first', () => { expect(sanitize_recents([ { kind: 'user', id: 'bob', name: 'bob' },