Skip to content

Commit f0a1226

Browse files
committed
fix(workspace): block symlink escapes in workspace protocol
Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 3f28481 commit f0a1226

2 files changed

Lines changed: 69 additions & 7 deletions

File tree

‎apps/desktop/src/main/workspace-protocol.test.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { mkdtemp, writeFile } from 'node:fs/promises';
1+
import { mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises';
22
import os from 'node:os';
33
import path from 'node:path';
44
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5-
import { resolveWorkspaceUrl } from './workspace-protocol';
5+
import { resolveWorkspaceRealPath, resolveWorkspaceUrl } from './workspace-protocol';
66

77
describe('resolveWorkspaceUrl', () => {
88
let workspaceDir: string;
@@ -163,4 +163,32 @@ describe('resolveWorkspaceUrl', () => {
163163
expect(r.ok).toBe(true);
164164
if (r.ok) expect(r.value.relPath).toBe('My File.html');
165165
});
166+
167+
it('rejects symlinks that resolve outside the workspace', async () => {
168+
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'oc-wsproto-outside-'));
169+
const outsideFile = path.join(outsideDir, 'secret.html');
170+
await writeFile(outsideFile, '<html>secret</html>');
171+
await symlink(outsideFile, path.join(workspaceDir, 'linked.html'));
172+
173+
const r = resolveWorkspaceUrl(`workspace://${designId}/linked.html`, resolveWorkspace);
174+
expect(r.ok).toBe(true);
175+
if (!r.ok) return;
176+
177+
const checked = await resolveWorkspaceRealPath(r.value);
178+
expect(checked).toEqual({ ok: false, error: 'traversal' });
179+
});
180+
181+
it('accepts symlinks that stay inside the workspace', async () => {
182+
await writeFile(path.join(workspaceDir, 'target.html'), '<html>target</html>');
183+
await symlink(path.join(workspaceDir, 'target.html'), path.join(workspaceDir, 'inside.html'));
184+
185+
const r = resolveWorkspaceUrl(`workspace://${designId}/inside.html`, resolveWorkspace);
186+
expect(r.ok).toBe(true);
187+
if (!r.ok) return;
188+
189+
const checked = await resolveWorkspaceRealPath(r.value);
190+
expect(checked.ok).toBe(true);
191+
if (checked.ok)
192+
expect(checked.value.absPath).toBe(await realpath(path.join(workspaceDir, 'target.html')));
193+
});
166194
});

‎apps/desktop/src/main/workspace-protocol.ts‎

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFile } from 'node:fs/promises';
1+
import { readFile, realpath } from 'node:fs/promises';
22
import path_module from 'node:path';
33
import type { CoreLogger } from '@open-codesign/core';
44
import type Database from 'better-sqlite3';
@@ -97,6 +97,7 @@ export interface WorkspaceResolution {
9797
mime: string;
9898
designId: string;
9999
relPath: string;
100+
workspacePath: string;
100101
}
101102

102103
export interface WorkspaceResolveResult {
@@ -163,7 +164,31 @@ export function resolveWorkspaceUrl(
163164
return { ok: false, error: 'unsupported_mime' };
164165
}
165166

166-
return { ok: true, value: { absPath, mime, designId, relPath } };
167+
return {
168+
ok: true,
169+
value: { absPath, mime, designId, relPath, workspacePath: normalizedWorkspace },
170+
};
171+
}
172+
173+
export async function resolveWorkspaceRealPath(
174+
resolution: WorkspaceResolution,
175+
): Promise<WorkspaceResolveResult | WorkspaceResolveFailure> {
176+
const realWorkspace = await realpath(resolution.workspacePath);
177+
const realAbsPath = await realpath(resolution.absPath);
178+
const sep = path_module.sep;
179+
const isInside =
180+
realAbsPath === realWorkspace || realAbsPath.startsWith(`${realWorkspace}${sep}`);
181+
if (!isInside) {
182+
return { ok: false, error: 'traversal' };
183+
}
184+
return {
185+
ok: true,
186+
value: {
187+
...resolution,
188+
absPath: realAbsPath,
189+
workspacePath: realWorkspace,
190+
},
191+
};
167192
}
168193

169194
export function registerWorkspaceScheme(): void {
@@ -225,15 +250,24 @@ export function registerWorkspaceProtocolHandler(opts: RegisterWorkspaceProtocol
225250
}
226251

227252
try {
228-
const data = await readFile(result.value.absPath);
229-
const isHtml = result.value.mime.startsWith('text/html');
253+
const realPathResult = await resolveWorkspaceRealPath(result.value);
254+
if (!realPathResult.ok) {
255+
logger.warn('workspace.protocol.reject', { url: request.url, error: realPathResult.error });
256+
return new Response(realPathResult.error, {
257+
status: 403,
258+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
259+
});
260+
}
261+
262+
const data = await readFile(realPathResult.value.absPath);
263+
const isHtml = realPathResult.value.mime.startsWith('text/html');
230264
const body: string | Uint8Array = isHtml
231265
? `${data.toString('utf8')}${WORKSPACE_NAV_INTERCEPT_SCRIPT}`
232266
: (data as unknown as Uint8Array);
233267
return new Response(body, {
234268
status: 200,
235269
headers: {
236-
'Content-Type': result.value.mime,
270+
'Content-Type': realPathResult.value.mime,
237271
// Iframe is sandboxed (opaque origin); permissive CORS keeps fetch()
238272
// and dynamic <script> imports working when pages need them.
239273
'Access-Control-Allow-Origin': '*',

0 commit comments

Comments
 (0)