Skip to content

Commit d19ee91

Browse files
committed
Fix packaged UI authorization on Windows short paths
1 parent 80a2ff6 commit d19ee91

9 files changed

Lines changed: 130 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
UFriend brings email, recordings, image text, and everyday writing into one local workspace. Its primary hardware target is **ASUS UGen300**: the goal is to turn a dedicated USB AI accelerator into an everyday desktop assistant. Review the original material alongside each result, edit it, and decide how to use it. The interface supports English and Traditional Chinese, with light, dark, and system themes.
1212

13-
**Version: 0.1.41 · Windows x64 · Electron 44**
13+
**Version: 0.1.42 · Windows x64 · Electron 44**
1414

1515
[Releases](https://github.com/breeze0305/UFriend/releases) · [Documentation](docs/README.md) · [User manual](docs/guides/APP_README.md) · [Report an issue](https://github.com/breeze0305/UFriend/issues)
1616

‎README.zh-TW.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
UFriend 將郵件、錄音、圖片文字與日常寫作整合在同一個本機工作空間,並以 **ASUS UGen300** 為主要目標硬體:讓專用 USB AI 加速器成為日常使用的桌面助理。你可以對照原始素材與各項處理結果、編輯內容,再決定如何使用。介面支援英文與繁體中文,以及淺色、深色和跟隨系統的佈景主題。
1212

13-
**版本:0.1.41 · Windows x64 · Electron 44**
13+
**版本:0.1.42 · Windows x64 · Electron 44**
1414

1515
[下載版本](https://github.com/breeze0305/UFriend/releases) · [文件導覽](docs/README.zh-TW.md) · [使用手冊](docs/guides/APP_README.zh-TW.md) · [回報問題](https://github.com/breeze0305/UFriend/issues)
1616

‎app/main.cjs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const {meetingCleanupInfo,clearMeetingMedia}=require('./services/media-cleanup.c
2020
const {clearAllChats,clearAllLogs}=require('./services/data-cleanup.cjs');
2121
const {loadRemoteImage,imageDataUrl}=require('./services/mail-images.cjs');
2222
const {assetResponse}=require('./services/asset-protocol.cjs');
23+
const {isTrustedUiUrl,isTrustedUiSender}=require('./services/ui-origin.cjs');
2324
const {VideoExportJobs}=require('./services/video-export-jobs.cjs');
2425
const {resolveVideoFfmpegPath}=require('./services/video-runtime.cjs');
2526
const {MediaRuntimeSetup}=require('./services/media-runtime-setup.cjs');
@@ -386,7 +387,7 @@ const actions={
386387
'pool.cancel':args=>{pool.cancelJob(args.id);broadcast();return pool.snapshot();}
387388
};
388389

389-
async function createWindow(){win=new BrowserWindow({width:1080,height:820,minWidth:640,minHeight:480,title:tr('UFriend | 你的本地助手'),icon:path.join(__dirname,'assets','ufriend.ico'),show:!smoke,backgroundColor:'#202321',webPreferences:{preload:path.join(__dirname,'preload.cjs'),contextIsolation:true,nodeIntegration:false,sandbox:true,webSecurity:true}});win.webContents.setWindowOpenHandler(()=>({action:'deny'}));win.webContents.on('will-navigate',(event,url)=>{if(url!==uiUrl)event.preventDefault();});win.webContents.on('will-frame-navigate',event=>{if(event.url!==uiUrl&&event.url!=='about:srcdoc')event.preventDefault();});win.webContents.session.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false));win.webContents.on('render-process-gone',()=>log('介面程序中斷','請重新開啟 UFriend。','error'));win.on('close',event=>{if(!quitting&&!smoke){event.preventDefault();if(store.read().settings.keepBackground&&tray){flushWindow().then(()=>win.hide()).catch(e=>dialog.showErrorBox(tr('資料尚未保存'),e.message));}else requestQuit();}});await win.loadFile(path.join(__dirname,'index.html'));}
390+
async function createWindow(){win=new BrowserWindow({width:1080,height:820,minWidth:640,minHeight:480,title:tr('UFriend | 你的本地助手'),icon:path.join(__dirname,'assets','ufriend.ico'),show:!smoke,backgroundColor:'#202321',webPreferences:{preload:path.join(__dirname,'preload.cjs'),contextIsolation:true,nodeIntegration:false,sandbox:true,webSecurity:true}});win.webContents.setWindowOpenHandler(()=>({action:'deny'}));win.webContents.on('will-navigate',(event,url)=>{if(!isTrustedUiUrl(url,uiUrl))event.preventDefault();});win.webContents.on('will-frame-navigate',event=>{if(!isTrustedUiUrl(event.url,uiUrl)&&event.url!=='about:srcdoc')event.preventDefault();});win.webContents.session.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false));win.webContents.on('render-process-gone',()=>log('介面程序中斷','請重新開啟 UFriend。','error'));win.on('close',event=>{if(!quitting&&!smoke){event.preventDefault();if(store.read().settings.keepBackground&&tray){flushWindow().then(()=>win.hide()).catch(e=>dialog.showErrorBox(tr('資料尚未保存'),e.message));}else requestQuit();}});await win.loadFile(path.join(__dirname,'index.html'));}
390391

391392
app.whenReady().then(async()=>{
392393
fs.mkdirSync(dataDir,{recursive:true});diagnostics=new Diagnostics({directory:path.join(dataDir,'diagnostics'),version:app.getVersion()});await diagnostics.write({event:'app.started'});store=new Store({dataDir});vault=new Vault({dataDir,safeStorage});
@@ -404,7 +405,7 @@ app.whenReady().then(async()=>{
404405
pool=new ModelPoolScheduler({onEvent:event=>{if(['job-completed','device-added','device-removed','policy-changed'].includes(event.type))log('裝置池模擬',event.type);broadcast();}});pool.setPolicy(store.read().settings.policy);
405406
simulatedPolicyTimer=setInterval(()=>pool.tick(),100);simulatedPolicyTimer.unref();
406407
protocol.handle('ufriend-asset',request=>assetResponse(request,id=>core.asset(id)));
407-
ipcMain.handle('ufriend:call',async(event,name,args)=>{if(!win||win.isDestroyed()||event.senderFrame?.url!==uiUrl||event.sender!==win.webContents)return{ok:false,error:{code:'FORBIDDEN',message:'操作來源不受信任。'}};try{if(!Object.hasOwn(actions,name))throw new Error(tr('不支援的操作。'));if(args===null||typeof args!=='object')throw new Error(tr('操作格式錯誤。'));const value=await actions[name](args);return{ok:true,value};}catch(e){diagnostics.write({event:'ipc.failed',level:'error',action:Object.hasOwn(actions,name)?name:'unknown',code:e.code||'ACTION_FAILED',requestId:e.requestId,errorName:e.name});return{ok:false,error:trimError(e)};}});
408+
ipcMain.handle('ufriend:call',async(event,name,args)=>{if(!isTrustedUiSender(event,win,uiUrl))return{ok:false,error:{code:'FORBIDDEN',message:tr('操作來源不受信任。')}};try{if(!Object.hasOwn(actions,name))throw new Error(tr('不支援的操作。'));if(args===null||typeof args!=='object')throw new Error(tr('操作格式錯誤。'));const value=await actions[name](args);return{ok:true,value};}catch(e){diagnostics.write({event:'ipc.failed',level:'error',action:Object.hasOwn(actions,name)?name:'unknown',code:e.code||'ACTION_FAILED',requestId:e.requestId,errorName:e.name});return{ok:false,error:trimError(e)};}});
408409
nativeTheme.themeSource=store.read().settings.theme;
409410
if(!smoke){const icon=nativeImage.createFromPath(path.join(__dirname,'assets','ufriend.png'));if(!icon.isEmpty()){tray=new Tray(icon);tray.setToolTip(tr('UFriend | 你的本地助手'));tray.setContextMenu(Menu.buildFromTemplate(trayMenuTemplate()));tray.on('double-click',()=>{win.show();win.focus();});}}
410411
Menu.setApplicationMenu(null);await createWindow();log('開啟 UFriend');scheduleSync();core.startRecordingScheduler();if(!smoke){mailScheduler=createMailScheduler();mailScheduler.start();}if(!smoke&&store.read().settings.ocrAuto)startClipboard();

‎app/services/ui-origin.cjs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use strict';
2+
3+
const path = require('node:path');
4+
const { fileURLToPath } = require('node:url');
5+
6+
function localDocumentPath(value) {
7+
if (typeof value !== 'string' || !value) return null;
8+
const url = new URL(value);
9+
// A file URL has no useful unique web origin. Authorize only the expected
10+
// local document, not other files, remote hosts, or URL-supplied state.
11+
if (url.protocol !== 'file:' || url.hostname || url.username || url.password
12+
|| url.href.includes('?') || url.href.includes('#')) return null;
13+
const file = path.normalize(fileURLToPath(url));
14+
return process.platform === 'win32' ? file.toLowerCase() : file;
15+
}
16+
17+
function isTrustedUiUrl(value, expectedUrl) {
18+
try {
19+
const expected = localDocumentPath(expectedUrl);
20+
return expected !== null && localDocumentPath(value) === expected;
21+
} catch { return false; }
22+
}
23+
24+
function isTrustedUiSender(event, win, expectedUrl) {
25+
try {
26+
if (!win || win.isDestroyed()) return false;
27+
const contents = win.webContents;
28+
return !!contents && !contents.isDestroyed()
29+
&& event?.sender === contents && !!event.senderFrame
30+
&& event.senderFrame === contents.mainFrame
31+
&& isTrustedUiUrl(event.senderFrame.url, expectedUrl);
32+
} catch { return false; }
33+
}
34+
35+
module.exports = { isTrustedUiUrl, isTrustedUiSender };

‎docs/guides/INSTALL_BUILD.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ Folder packages are placed in `dist/UFriend-<version>/`. If a folder for the sam
223223
If the latest successfully validated folder package for the same version already exists, use its actual output path to skip rebuilding it:
224224

225225
```powershell
226-
npm run package:portable -- --prepackaged 'dist/UFriend-0.1.41'
226+
npm run package:portable -- --prepackaged 'dist/UFriend-0.1.42'
227227
# Add --with-ffmpeg if that folder package already contains FFmpeg.
228228
```
229229

‎docs/guides/INSTALL_BUILD.zh-TW.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ npm run package -- --without-ffmpeg
223223
已有同版本最新驗證成功的資料夾包時,可依實際輸出路徑省略重建這一步:
224224

225225
```powershell
226-
npm run package:portable -- --prepackaged 'dist/UFriend-0.1.41'
226+
npm run package:portable -- --prepackaged 'dist/UFriend-0.1.42'
227227
# 若該資料夾包已含 FFmpeg,另加 --with-ffmpeg
228228
```
229229

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ufriend",
33
"productName": "UFriend",
4-
"version": "0.1.41",
4+
"version": "0.1.42",
55
"private": true,
66
"description": "A local-first Windows workspace for email, transcription, OCR and AI writing.",
77
"main": "app/main.cjs",

‎tests/ui-origin.test.cjs‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
const path = require('node:path');
6+
const { pathToFileURL } = require('node:url');
7+
const { isTrustedUiUrl, isTrustedUiSender } = require('../app/services/ui-origin.cjs');
8+
9+
const uiFile = path.resolve('synthetic BREEZE~1', '中文 app', 'index.html');
10+
const uiUrl = pathToFileURL(uiFile).href;
11+
const chromiumUrl = uiUrl.replace(/%7e/ig, '~');
12+
13+
function fixture(url = chromiumUrl) {
14+
const frame = { url };
15+
const contents = { mainFrame: frame, isDestroyed: () => false };
16+
const win = { webContents: contents, isDestroyed: () => false };
17+
return { win, contents, frame, event: { sender: contents, senderFrame: frame } };
18+
}
19+
20+
test('the same UI document accepts both Node and Chromium tilde encodings', () => {
21+
const encoded = chromiumUrl.replace('~', '%7E');
22+
assert.equal(isTrustedUiUrl(chromiumUrl, encoded), true);
23+
assert.equal(isTrustedUiUrl(encoded, chromiumUrl), true);
24+
assert.equal(isTrustedUiUrl(encoded.replace('%7E', '%7e'), encoded), true);
25+
assert.equal(isTrustedUiSender(fixture().event, fixture().win, uiUrl), false);
26+
const h = fixture();
27+
assert.equal(isTrustedUiSender(h.event, h.win, encoded), true);
28+
});
29+
30+
test('Windows file path case differences do not reject the same local UI', { skip: process.platform !== 'win32' }, () => {
31+
const lowerDrive = uiUrl.replace(/^file:\/\/\/[A-Z]:/, value => value.toLowerCase());
32+
assert.equal(isTrustedUiUrl(lowerDrive, uiUrl), true);
33+
assert.equal(isTrustedUiUrl(uiUrl.toUpperCase(), uiUrl), true);
34+
});
35+
36+
test('only the exact local UI path is accepted', () => {
37+
for (const url of [
38+
pathToFileURL(path.join(path.dirname(uiFile), 'other.html')).href,
39+
uiUrl + '.evil', uiUrl.replace('/index.html', '/child/index.html'),
40+
uiUrl.replace('/index.html', '/child/../../index.html'),
41+
uiUrl.replace(/%7e/ig, '%257E'),
42+
uiUrl.replace('/index.html', '%2Findex.html'),
43+
uiUrl.replace('/index.html', '%5Cindex.html'),
44+
uiUrl.replace('/index.html', '/%ZZindex.html'),
45+
uiUrl.replace('/index.html', '/%E0%A4index.html'),
46+
'https://example.invalid/index.html', 'data:text/html,UI',
47+
'about:blank', 'about:srcdoc', 'file://remote-host/share/index.html',
48+
'', null, undefined, { href: uiUrl },
49+
]) assert.equal(isTrustedUiUrl(url, uiUrl), false, String(url));
50+
for (const suffix of ['?x=1', '#translation', '?', '#']) {
51+
assert.equal(isTrustedUiUrl(uiUrl + suffix, uiUrl), false, suffix);
52+
}
53+
});
54+
55+
test('reserved characters in actual filenames remain distinct from URL state', () => {
56+
const name = pathToFileURL(path.resolve('app #1', '100% ready', 'index.html')).href;
57+
assert.equal(isTrustedUiUrl(name, name), true);
58+
assert.equal(isTrustedUiUrl(name + '#', name), false);
59+
});
60+
61+
test('same-URL subframes and other windows never receive privileged actions', () => {
62+
const h = fixture();
63+
assert.equal(isTrustedUiSender(h.event, h.win, uiUrl), true);
64+
assert.equal(isTrustedUiSender({ ...h.event, senderFrame: { url: chromiumUrl, parent: h.frame } }, h.win, uiUrl), false);
65+
assert.equal(isTrustedUiSender({ ...h.event, sender: fixture().contents }, h.win, uiUrl), false);
66+
for (const senderFrame of [null, undefined]) {
67+
assert.equal(isTrustedUiSender({ ...h.event, senderFrame }, h.win, uiUrl), false);
68+
}
69+
h.frame.url = 'about:srcdoc';
70+
assert.equal(isTrustedUiSender(h.event, h.win, uiUrl), false);
71+
});
72+
73+
test('closed windows and disposed frame metadata fail closed', () => {
74+
const h = fixture();
75+
assert.equal(isTrustedUiSender(h.event, null, uiUrl), false);
76+
assert.equal(isTrustedUiSender(null, h.win, uiUrl), false);
77+
h.win.isDestroyed = () => true;
78+
assert.equal(isTrustedUiSender(h.event, h.win, uiUrl), false);
79+
h.win.isDestroyed = () => false;
80+
h.contents.isDestroyed = () => true;
81+
assert.equal(isTrustedUiSender(h.event, h.win, uiUrl), false);
82+
h.contents.isDestroyed = () => false;
83+
Object.defineProperty(h.frame, 'url', { get() { throw new Error('Frame was disposed'); } });
84+
assert.equal(isTrustedUiSender(h.event, h.win, uiUrl), false);
85+
});

0 commit comments

Comments
 (0)