feat(v028): inkling-media:// custom protocol + path traversal 검사
- registerSchemesAsPrivileged: inkling-media 스킴을 secure + supportFetchAPI + stream 으로 등록 (whenReady 이전 호출 필수). - registerInklingMediaProtocol: profileDir/media 하위 파일을 raw URL traversal (.., %2e%2e) 검사 + normalize 후 mediaRoot 봉쇄로 이중 검증 후 readFile. - inferMime: png/jpg/jpeg/gif/webp → image/*, 그 외 → application/octet-stream. - src/main/index.ts: 모듈 import 직후 registerSchemesAsPrivileged(), whenReady 안 paths 결정 직후 registerInklingMediaProtocol(paths.profileDir). - tests/unit/inklingMedia.test.ts: 8 unit (5 inferMime + 3 handler — valid/403/404). vitest 의 new Request() 가 url 을 normalize 하므로 raw url 보존을 위해 minimal mock req 사용.
This commit is contained in:
@@ -33,11 +33,17 @@ import { SyncService } from './services/SyncService.js';
|
||||
import { TelemetryService } from './services/TelemetryService.js';
|
||||
import { SettingsService } from './services/SettingsService.js';
|
||||
import { collectAutostartState } from './services/AutostartDiagnostic.js';
|
||||
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../shared/constants.js';
|
||||
|
||||
const HIDDEN_ARG = '--hidden';
|
||||
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
||||
|
||||
// v0.2.8 Cut A — `inkling-media://` custom protocol 스킴은 app.whenReady() 전에
|
||||
// privileged 등록 필수 (Electron 표준). 이미지 asset 을 main process 가 직접
|
||||
// 서빙해 file:// hack 없이 작동.
|
||||
registerSchemesAsPrivileged();
|
||||
|
||||
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
|
||||
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
|
||||
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
|
||||
@@ -73,6 +79,9 @@ app.whenReady().then(async () => {
|
||||
|
||||
const paths = resolveProfilePaths('default');
|
||||
|
||||
// v0.2.8 Cut A — `inkling-media://` request handler 등록 (profileDir 결정 후).
|
||||
registerInklingMediaProtocol(paths.profileDir);
|
||||
|
||||
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
|
||||
void telemetry.cleanupOldFiles()
|
||||
.then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }))
|
||||
|
||||
53
src/main/protocol/inklingMedia.ts
Normal file
53
src/main/protocol/inklingMedia.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import electron from 'electron';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, normalize, sep, extname } from 'node:path';
|
||||
|
||||
const { protocol } = electron;
|
||||
|
||||
export function registerSchemesAsPrivileged(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
|
||||
]);
|
||||
}
|
||||
|
||||
export function inferMime(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png': return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg': return 'image/jpeg';
|
||||
case '.gif': return 'image/gif';
|
||||
case '.webp': return 'image/webp';
|
||||
default: return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerInklingMediaProtocol(profileDir: string): void {
|
||||
const mediaRoot = join(profileDir, 'media');
|
||||
protocol.handle('inkling-media', async (req) => {
|
||||
// Raw URL 에서 `..` 세그먼트 (URL-encoded 포함) 검출 — `new URL()` 이 normalize 하기 전에 차단.
|
||||
const rawLower = req.url.toLowerCase();
|
||||
if (
|
||||
rawLower.includes('/../') ||
|
||||
rawLower.endsWith('/..') ||
|
||||
rawLower.includes('/%2e%2e/') ||
|
||||
rawLower.endsWith('/%2e%2e')
|
||||
) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
const url = new URL(req.url);
|
||||
const relPath = decodeURIComponent((url.host + url.pathname).replace(/^media\//, 'media/'));
|
||||
const target = normalize(join(profileDir, relPath));
|
||||
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const data = await readFile(target);
|
||||
return new Response(new Uint8Array(data), {
|
||||
headers: { 'content-type': inferMime(target) }
|
||||
});
|
||||
} catch {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
});
|
||||
}
|
||||
74
tests/unit/inklingMedia.test.ts
Normal file
74
tests/unit/inklingMedia.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { join, sep } from 'node:path';
|
||||
|
||||
const { mockReadFile, mockHandle, mockRegisterSchemes } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockHandle: vi.fn(),
|
||||
mockRegisterSchemes: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: mockReadFile
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
default: {
|
||||
protocol: {
|
||||
registerSchemesAsPrivileged: mockRegisterSchemes,
|
||||
handle: mockHandle
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';
|
||||
|
||||
describe('inferMime', () => {
|
||||
it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
|
||||
it('returns image/jpeg for .jpg and .jpeg', () => {
|
||||
expect(inferMime('foo.jpg')).toBe('image/jpeg');
|
||||
expect(inferMime('foo.jpeg')).toBe('image/jpeg');
|
||||
});
|
||||
it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
|
||||
it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
|
||||
it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
|
||||
});
|
||||
|
||||
describe('inkling-media protocol handler', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
function getHandler(profileDir: string): (req: { url: string }) => Promise<Response> {
|
||||
registerInklingMediaProtocol(profileDir);
|
||||
const call = mockHandle.mock.calls[0];
|
||||
if (!call) throw new Error('protocol.handle not called');
|
||||
return call[1] as (req: { url: string }) => Promise<Response>;
|
||||
}
|
||||
|
||||
// 실 운영 (Electron protocol.handle) 에서는 req.url 이 raw 문자열로 전달되지만,
|
||||
// vitest 의 `new Request()` constructor 는 url 을 즉시 normalize (`/../` 제거) 함.
|
||||
// 따라서 traversal 검사 로직이 raw URL 단계에서 작동하는지 검증하려면
|
||||
// raw url 을 보존한 minimal mock 을 직접 전달.
|
||||
function rawReq(url: string): { url: string } { return { url }; }
|
||||
|
||||
it('serves valid file with correct mime', async () => {
|
||||
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(rawReq('inkling-media://media/note1/img.png'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('image/png');
|
||||
expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
|
||||
});
|
||||
|
||||
it('returns 403 on path traversal attempt', async () => {
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(rawReq('inkling-media://media/../etc/passwd'));
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 when file missing', async () => {
|
||||
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
|
||||
const handler = getHandler('/profile');
|
||||
const res = await handler(rawReq('inkling-media://media/note1/missing.png'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user