Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,13 @@ describe('fetchLocalMediaToOss — scheme 路由', () => {
const result = await fetchLocalMediaToOss({ url });

expect(getSessionFsSnapshot).toHaveBeenCalledWith('session-ssh');
// 不带 baseDir / maxBytes 的普通媒体取件:limits 必须是 undefined,
// 不能凑一个空对象出来(那会让 materialize 侧分不清"没要求"与"要求为空")。
expect(materializeSshRemoteMedia).toHaveBeenCalledWith(
{ remoteHostId: 'host-1', workdir: '/home/u/proj' },
url,
undefined,
undefined,
);
expect(realpathMock).not.toHaveBeenCalled();
expect(uploadLocalFile).toHaveBeenCalledWith('/cache/ssh/plot.png', {
Expand Down Expand Up @@ -466,3 +470,137 @@ describe('thumbnail 护栏(输入体量 + 渲染超时)', () => {
expect(uploadLocalFile).toHaveBeenCalledTimes(1);
});
});

/**
* HTML 资源透传带来的两道服务端强制约束(review P1 security / P2)。
*
* 为什么必须在被控端判:控制端的词法 `..` 校验只保证**词法**子树 —— 产物目录里若有指向
* 目录外的软链,词法路径完全合法,而原实现 realpath 后只比对全局敏感目录 blocklist,于是
* blocklist 之外的用户文件会被取回、内联进不可信页面;大小同理,控制端拿到 `media.size`
* 时字节已经上传完 OSS(SSH 还先整份拉进 Desktop 缓存),流量已经花掉。
*/
describe('fetchLocalMediaToOss — baseDir / maxBytes 服务端强制约束', () => {
const urlFor = (p: string, q = ''): string =>
`xdt-file://local/?path=${encodeURIComponent(p)}${q}`;

it('资源 realpath 落在 baseDir realpath 子树内 → 放行', async () => {
realpathMock.mockImplementation(async (p: string) => p);
await fetchLocalMediaToOss({
url: urlFor('/proj/out/assets/a.png', `&baseDir=${encodeURIComponent('/proj/out')}`),
});
expect(uploadLocalFile).toHaveBeenCalledTimes(1);
});

it('资源就是 baseDir 自己(相等)→ 放行', async () => {
realpathMock.mockImplementation(async (p: string) => p);
await fetchLocalMediaToOss({
url: urlFor('/proj/out', `&baseDir=${encodeURIComponent('/proj/out')}`),
});
expect(uploadLocalFile).toHaveBeenCalledTimes(1);
});

it('产物目录里的软链指向 baseDir 之外 → 拒绝,且不上传(词法校验挡不住的那条)', async () => {
// 请求路径 /proj/out/leak.png 词法完全合法(无 `..`),realpath 却落在别的用户目录;
// 该目录不在敏感目录 blocklist 里,所以只有 baseDir 包含判定能挡住。
realpathMock.mockImplementation(async (p: string) => (
p === path.resolve('/proj/out/leak.png') ? path.resolve('/Users/me/private/notes.png') : p
));
const msg = await codeOf(() => fetchLocalMediaToOss({
url: urlFor('/proj/out/leak.png', `&baseDir=${encodeURIComponent('/proj/out')}`),
}));
expect(msg).toMatch(/不在允许的基目录内/);
expect(uploadLocalFile).not.toHaveBeenCalled();
});

it('baseDir 自身是软链(/tmp → /private/tmp)时不误拒:两侧都取 realpath', async () => {
realpathMock.mockImplementation(async (p: string) => (
p.startsWith('/tmp') ? p.replace('/tmp', '/private/tmp') : p
));
await fetchLocalMediaToOss({
url: urlFor('/tmp/out/a.png', `&baseDir=${encodeURIComponent('/tmp/out')}`),
});
expect(uploadLocalFile).toHaveBeenCalledTimes(1);
});

it('baseDir 解析不了 → fail-closed 拒绝,不上传', async () => {
realpathMock.mockImplementation(async (p: string) => {
if (p === path.resolve('/proj/gone')) throw new Error('ENOENT');
return p;
});
const msg = await codeOf(() => fetchLocalMediaToOss({
url: urlFor('/proj/gone/a.png', `&baseDir=${encodeURIComponent('/proj/gone')}`),
}));
expect(msg).toMatch(/基目录不存在或不可读/);
expect(uploadLocalFile).not.toHaveBeenCalled();
});

it('baseDir 畸形(非绝对 / 空)→ 抛错,不静默降级成"不约束"', async () => {
expect(await codeOf(() => fetchLocalMediaToOss({
url: urlFor('/proj/out/a.png', '&baseDir=out'),
}))).toMatch(/baseDir 必须为绝对路径/);
expect(await codeOf(() => fetchLocalMediaToOss({
url: urlFor('/proj/out/a.png', '&baseDir=%20'),
}))).toMatch(/baseDir 不能为空/);
expect(uploadLocalFile).not.toHaveBeenCalled();
});

it('maxBytes:stat 超限 → 上传之前就拒绝(流量一个字节都不花)', async () => {
realpathMock.mockImplementation(async (p: string) => p);
statMock.mockResolvedValue({ size: 5_000_000, mtimeMs: 1 });
const msg = await codeOf(() => fetchLocalMediaToOss({
url: urlFor('/proj/out/big.css', '&maxBytes=2097152'),
}));
expect(msg).toMatch(/超出取件大小上限/);
expect(uploadLocalFile).not.toHaveBeenCalled();
});

it('maxBytes:上限内正常放行', async () => {
realpathMock.mockImplementation(async (p: string) => p);
statMock.mockResolvedValue({ size: 1024, mtimeMs: 1 });
await fetchLocalMediaToOss({ url: urlFor('/proj/out/a.css', '&maxBytes=2097152') });
expect(uploadLocalFile).toHaveBeenCalledTimes(1);
});

it('maxBytes 畸形(非正整数)→ 抛错,不静默降级成"不约束"', async () => {
for (const raw of ['0', '-1', 'abc', '1.5']) {
expect(await codeOf(() => fetchLocalMediaToOss({
url: urlFor('/proj/out/a.png', `&maxBytes=${encodeURIComponent(raw)}`),
}))).toMatch(/maxBytes 必须为正整数/);
}
expect(uploadLocalFile).not.toHaveBeenCalled();
});

it('两个参数都不带 → 行为不变(老控制端照旧取件)', async () => {
realpathMock.mockImplementation(async (p: string) => p);
statMock.mockResolvedValue({ size: 5_000_000, mtimeMs: 1 });
await fetchLocalMediaToOss({ url: urlFor('/proj/out/big.css') });
expect(uploadLocalFile).toHaveBeenCalledTimes(1);
});

it('SSH 分支:两项约束原样下发给 materializeSshRemoteMedia(它 stat 完就会拉整份文件)', async () => {
const url = 'xdt-file://local/?path=' + encodeURIComponent('/home/u/proj/out/a.png')
+ '&sessionId=s1&remoteHostId=host-1&workdir=' + encodeURIComponent('/home/u/proj')
+ '&baseDir=' + encodeURIComponent('/home/u/proj/out')
+ '&maxBytes=2097152';
await fetchLocalMediaToOss({ url });
expect(materializeSshRemoteMedia).toHaveBeenCalledWith(
{ remoteHostId: 'host-1', workdir: '/home/u/proj' },
url,
undefined,
{ baseDir: path.resolve('/home/u/proj/out'), maxBytes: 2097152 },
);
// SSH 分支不得再走本机 realpath / 本机 stat 门禁(缓存文件不是被约束对象)。
expect(realpathMock).not.toHaveBeenCalled();
});
});

describe('__testing.isInsideRealDir', () => {
it('子树内 / 相等 → true;越界 / 兄弟前缀 → false', () => {
const inside = __testing.isInsideRealDir;
expect(inside(path.resolve('/a/b/c.png'), path.resolve('/a/b'))).toBe(true);
expect(inside(path.resolve('/a/b'), path.resolve('/a/b'))).toBe(true);
expect(inside(path.resolve('/a/c.png'), path.resolve('/a/b'))).toBe(false);
// 兄弟目录靠字符串前缀会误判成"在内"(`/a/bb` 以 `/a/b` 开头),必须按路径段比。
expect(inside(path.resolve('/a/bb/c.png'), path.resolve('/a/b'))).toBe(false);
});
});
96 changes: 95 additions & 1 deletion apps/desktop/src/main/device-link/mediaFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,62 @@ function parsePathQuery(url: string): string {
return path.resolve(p);
}

/**
* xdt-file/audio URL 上的**服务端强制约束**(手机端 HTML 资源透传会带;普通媒体取件不带)。
*
* 为什么要在被控端强制,而不是信控制端已经判过:
* - `baseDir` —— 控制端只能做词法校验(拒 `..`),那**只能保证词法子树**。产物目录里若已
* 存在一个指向目录外的软链,词法路径完全合法,而本模块原先 realpath 后只比对全局敏感
* 目录 blocklist,于是 blocklist 之外的用户文件会被取回并内联进不可信页面(review P1
* security)。真正的边界只能在这里画:资源与 baseDir **各自 realpath 后**判定包含关系。
* - `maxBytes` —— 控制端拿到 `media.size` 时字节已经上传到 OSS(SSH 场景还先整份拉到
* Desktop 磁盘缓存),流量与磁盘已经花掉。一份不可信 HTML 引用一个 2 GB 的白名单扩展名
* 文件、再叠上 4 路并发,就能打出数 GB 的无用流量(review P2)。必须在 stat 之后、
* 上传/拉取之前拒绝。
*
* 缺省(参数不出现)= 不约束,老控制端行为不变。参数出现但畸形一律抛错(fail-closed),
* 不静默降级成"不约束"—— 那会让约束可被畸形输入摘掉。
*/
interface PathMediaConstraints {
/** 资源 realpath 必须落在此目录 realpath 的子树内;null = 不约束。 */
baseDir: string | null;
/** 字节上限(> 0);null = 不约束。 */
maxBytes: number | null;
}

function parsePathMediaConstraints(url: string): PathMediaConstraints {
const params = new URL(url).searchParams;
const rawBaseDir = params.get('baseDir');
const rawMaxBytes = params.get('maxBytes');

let baseDir: string | null = null;
if (rawBaseDir !== null) {
if (!rawBaseDir.trim()) throw new Error('媒体 baseDir 不能为空');
if (!(rawBaseDir.startsWith('/') || WIN_ABS_RE.test(rawBaseDir))) {
throw new Error('媒体 baseDir 必须为绝对路径');
}
baseDir = path.resolve(rawBaseDir);
}

let maxBytes: number | null = null;
if (rawMaxBytes !== null) {
const n = Number(rawMaxBytes);
if (!Number.isInteger(n) || n <= 0) throw new Error('媒体 maxBytes 必须为正整数');
maxBytes = n;
}
return { baseDir, maxBytes };
}

/**
* `realChild` 是否落在 `realBase` 子树内(相等也算)。**两侧都必须是 realpath 结果** ——
* 只 realpath 一侧时,`/tmp` → `/private/tmp` 这类平台软链会让合法资源被误拒。
* win32 上 `path.relative` 本身按大小写不敏感比较,无需额外归一化。
*/
function isInsideRealDir(realChild: string, realBase: string): boolean {
const rel = path.relative(realBase, realChild);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}

/**
* xdt-file/audio URL 上的 SSH 取件上下文。URL 只作声明,真正的 host/workdir
* 必须按 sessionId 从本地会话库反查,并与 URL 声明逐项一致后才可使用。
Expand Down Expand Up @@ -242,10 +298,21 @@ export async function fetchLocalMediaToOss(arg: unknown): Promise<MediaFetchResu
const skipCache = record.skipCache === true;
const isPathMedia = url.startsWith('xdt-file://') || url.startsWith('xdt-audio://');
const sshOrigin = isPathMedia ? await parseSshMediaOrigin(url) : null;
const constraints: PathMediaConstraints = isPathMedia
? parsePathMediaConstraints(url)
: { baseDir: null, maxBytes: null };
let absPath: string;
let mimeType: string | undefined;
if (sshOrigin) {
const materialized = await materializeSshRemoteMedia(sshOrigin, url);
// SSH 分支的两道约束必须在 materialize **内部**生效:它 stat 完就会把整份文件分片拉进
// Desktop 磁盘缓存,拉完再判等于流量已经花掉。
const sshLimits = constraints.baseDir !== null || constraints.maxBytes !== null
? {
...(constraints.baseDir ? { baseDir: constraints.baseDir } : {}),
...(constraints.maxBytes !== null ? { maxBytes: constraints.maxBytes } : {}),
}
: undefined;
const materialized = await materializeSshRemoteMedia(sshOrigin, url, undefined, sshLimits);
if (!materialized.ok) {
throw new Error(`SSH 媒体取回失败(${materialized.status}):${materialized.message}`);
}
Expand Down Expand Up @@ -283,11 +350,36 @@ export async function fetchLocalMediaToOss(arg: unknown): Promise<MediaFetchResu
log.warn(`media:fetch blocked sensitive realpath ${url.slice(0, 60)}`);
throw new Error('该路径位于敏感目录,已阻止远程取件');
}
// baseDir 包含判定(review P1 security):blocklist 只挡"敏感目录",挡不住"产物目录里
// 一个指向别的普通用户目录的软链"。两侧都取 realpath 后比较,才是真正的同目录约束。
if (constraints.baseDir) {
let realBase: string;
try {
realBase = await realpath(constraints.baseDir);
} catch {
// 基目录都解析不了就别猜(fail-closed):宁可这一个资源取不到、渲染成破图。
throw new Error('资源基目录不存在或不可读');
}
if (!isInsideRealDir(real, realBase)) {
log.warn(`media:fetch blocked out-of-base resource ${url.slice(0, 60)}`);
throw new Error('资源不在允许的基目录内,已阻止远程取件');
}
}
// 语义扩展名取请求路径(absPath 此时仍是请求路径),再切到 realpath 读字节。
uploadExtHint = path.extname(absPath);
absPath = real;
}

// 大小门禁:必须在 uploadLocalFile 之前(review P2)。SSH 分支已在 materialize 内部按
// 远端 stat 判过,这里只管本机分支(realpath 后 stat,与后续上传读的是同一个 inode)。
if (constraints.maxBytes !== null && !sshOrigin) {
const sizeStat = await stat(absPath);
if (sizeStat.size > constraints.maxBytes) {
log.warn(`media:fetch rejected oversize ${sizeStat.size}B > ${constraints.maxBytes}B ${url.slice(0, 60)}`);
throw new Error(`资源超出取件大小上限(${sizeStat.size} > ${constraints.maxBytes} 字节)`);
}
}

if (record.thumbnail === true && canThumbnail(absPath, mimeType)) {
try {
// 输入体量护栏:病态大图(> 48MB)解码成本失控,直接放弃缩图走原图路径;
Expand Down Expand Up @@ -350,6 +442,8 @@ export async function fetchLocalMediaToOss(arg: unknown): Promise<MediaFetchResu
export const __testing = {
resolveLocalMedia,
parsePathQuery,
parsePathMediaConstraints,
isInsideRealDir,
parseSshMediaOrigin,
uploadCache,
UPLOAD_CACHE_TTL_MS,
Expand Down
93 changes: 93 additions & 0 deletions apps/desktop/src/main/file-browser/__tests__/sshMedia.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,96 @@ describe('serveSshRemoteMedia', () => {
expect(r.status).toBe(502);
});
});

/**
* HTML 资源透传的两项约束(mediaFetch 透传下来)。两项都必须在**拉取之前**生效:
* 本函数 stat 完就会把整份文件分片拉进 Desktop 磁盘缓存,拉完再判等于 SSH 流量
* 与磁盘已经花掉(review P2)。
*/
describe('materializeSshRemoteMedia — baseDir / maxBytes 约束', () => {
const origin = { remoteHostId: 'host-1', workdir: '/home/u/proj' };
const urlFor = (p: string): string => `xdt-file://open?path=${encodeURIComponent(p)}`;

function makeDeps(size = 4): { deps: SshMediaDeps; fetchToCache: ReturnType<typeof vi.fn> } {
const fetchToCache = vi.fn(async () => {
const p = path.join(tmpDir, 'cached.png');
await writeFile(p, Buffer.from([1, 2, 3, 4]));
return p;
});
return {
fetchToCache,
deps: {
request: vi.fn(async () => ({ type: 'file', size, mtimeMs: 1 })) as unknown as SshMediaDeps['request'],
fetchToCache: fetchToCache as unknown as SshMediaDeps['fetchToCache'],
},
};
}

it('baseDir 内 → 放行', async () => {
const { deps } = makeDeps();
const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/a.png'), deps, {
baseDir: '/home/u/proj/out',
});
expect(r.ok).toBe(true);
});

it('baseDir 外 → 403,且不拉字节', async () => {
const { deps, fetchToCache } = makeDeps();
const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/other/a.png'), deps, {
baseDir: '/home/u/proj/out',
});
expect(r.ok).toBe(false);
expect(r.ok === false && r.status).toBe(403);
expect(fetchToCache).not.toHaveBeenCalled();
});

it('兄弟目录前缀相似(out2 vs out)不算在内', async () => {
const { deps } = makeDeps();
const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out2/a.png'), deps, {
baseDir: '/home/u/proj/out',
});
expect(r.ok === false && r.status).toBe(403);
});

it('baseDir 就是 workdir 根 → 不额外收窄(workdir 内一律放行)', async () => {
const { deps } = makeDeps();
const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/anywhere/a.png'), deps, {
baseDir: '/home/u/proj',
});
expect(r.ok).toBe(true);
});

it('baseDir 落在 workdir 外 → 403', async () => {
const { deps, fetchToCache } = makeDeps();
const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/a.png'), deps, {
baseDir: '/etc',
});
expect(r.ok === false && r.status).toBe(403);
expect(fetchToCache).not.toHaveBeenCalled();
});

it('maxBytes:远端 stat 超限 → 403,且不把文件拉进 Desktop 缓存', async () => {
const { deps, fetchToCache } = makeDeps(5_000_000);
const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/big.css'), deps, {
maxBytes: 2 * 1024 * 1024,
});
expect(r.ok === false && r.status).toBe(403);
expect(r.ok === false && r.message).toMatch(/超出取件大小上限/);
expect(fetchToCache).not.toHaveBeenCalled();
});

it('maxBytes 内 → 正常拉取', async () => {
const { deps, fetchToCache } = makeDeps(1024);
const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/a.css'), deps, {
maxBytes: 2 * 1024 * 1024,
});
expect(r.ok).toBe(true);
expect(fetchToCache).toHaveBeenCalledTimes(1);
});

it('不传 limits → 行为不变', async () => {
const { deps } = makeDeps(5_000_000);
const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/big.css'), deps);
expect(r.ok).toBe(true);
});
});
Loading
Loading