Files
RSSHub/lib/utils/evaluate-script.test.ts
2026-10-09 03:04:47 +08:00

178 lines
11 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { evaluateScriptCallback, evaluateScriptData } from './evaluate-script';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('serialized script data', () => {
it.each(['__DATA__', 'window.__DATA__', 'globalThis.__DATA__', 'self.__DATA__'])('normalizes the global target %s', async (target) => {
await expect(evaluateScriptData(String.raw`window.__DATA__ = { title: "Hello\u0020世界", tags: ["a", "b"] };`, target)).resolves.toEqual({ title: 'Hello 世界', tags: ['a', 'b'] });
});
it('resolves variables, nested assignments, and data-only unary and logical expressions', async () => {
const source = 'var title="A & B", unused=analytics(); window.pixiv=window.pixiv||{}; pixiv.novel={title, count:-2, available:!0, absent:void 0};';
await expect(evaluateScriptData(source, 'window.pixiv.novel')).resolves.toEqual({ title: 'A & B', count: -2, available: true, absent: undefined });
});
it('reads parent-object assignments and static bracket targets', async () => {
await expect(evaluateScriptData('window.pixiv={novel:{title:"Book"}};', 'self["pixiv"].novel')).resolves.toEqual({ title: 'Book' });
});
it('reads Nuxt IIFE parameters and object mutations without changing outer bindings', async () => {
const source = 'var a="outer";window.__NUXT__=(function(a,b,c){a.title=b;a.tags=c;return {data:[a,a],error:null}}({},"News",["tag"]));';
const value = await evaluateScriptData<{ data: Array<{ title: string; tags: string[] }>; error: null }>(source, '__NUXT__');
expect(value).toEqual({
data: [
{ title: 'News', tags: ['tag'] },
{ title: 'News', tags: ['tag'] },
],
error: null,
});
expect(value.data[0]).toBe(value.data[1]);
await expect(evaluateScriptData(source, 'a')).resolves.toBe('outer');
});
it('preserves repeated var declarations and evaluates unused IIFE arguments in order', async () => {
const source = 'var a={value:1};var a;window.__DATA__=(function(x){return x})(a,a.value=2);';
await expect(evaluateScriptData(source, '__DATA__')).resolves.toEqual({ value: 2 });
});
it('reads bounded Nuxt sparse-array arguments without calling the host Array constructor', async () => {
const source = 'window.__NUXT__=(function(a){a[1]={title:"News"};return {data:a}})(Array(3));';
const value = await evaluateScriptData<{ data: unknown[] }>(source, '__NUXT__');
expect(value.data).toHaveLength(3);
expect(0 in value.data).toBe(false);
expect(value.data[1]).toEqual({ title: 'News' });
expect(2 in value.data).toBe(false);
});
it.each([
'window.__DATA__=Array(-1);',
'window.__DATA__=Array(1.5);',
'window.__DATA__=Array(10000);',
'window.__DATA__=Array("3");',
'window.__DATA__=Array(1,2);',
'var Array=undefined;window.__DATA__=Array(3);',
'window.__DATA__=(function(Array){return Array(3)})(undefined);',
'window.__DATA__=(function(){var a=Array(3);var Array;return a})();',
'window.__DATA__=Array(3);function Array(){return ["shadowed"]}',
'window.__DATA__=(function Array(){return Array(3)})();',
'window.__DATA__=(function(){return Array(3);function Array(){return ["shadowed"]}})();',
'window.__DATA__=Array(3);if(false){var Array;}',
'window.__DATA__=Array(3);class Array {}',
'window.__DATA__=Array(3);var {constructorAlias:Array}={};',
'window.__DATA__=Array(3);var [Array]=[];',
'window.__DATA__=Array(3);var {nested:{Array=undefined}}={};',
'window.__DATA__=Array(3);var {...Array}={};',
'window.__DATA__=Array(3);if(false){var [Array]=[];}',
'window.__DATA__=(function(){return Array(3);try{}catch(error){var Array}})();',
])('rejects invalid or shadowed array constructors: %s', async (source) => {
await expect(evaluateScriptData(source, '__DATA__')).rejects.toThrow(/bounded|Shadowed|Unsupported|simple variable names/);
});
it('does not hoist bindings from nested functions or unrelated lexical blocks', async () => {
const source = 'window.__DATA__=Array(3);function unrelated(){var Array;}if(false){let Array;class Other{}}';
const value = await evaluateScriptData<unknown[]>(source, '__DATA__');
expect(value).toHaveLength(3);
expect(0 in value).toBe(false);
});
it('enforces a total complexity budget across multiple sparse arrays', async () => {
await expect(evaluateScriptData('window.__DATA__=[Array(6000),Array(6000)];', '__DATA__')).rejects.toThrow('complexity limit');
});
it('does not call unrelated application code or access the host global object', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const value = await evaluateScriptData('globalThis.__scriptDataSideEffect="changed";fetch("https://example.invalid/");window.__DATA__={ok:true};', '__DATA__');
expect(value).toEqual({ ok: true });
expect(fetchSpy).not.toHaveBeenCalled();
expect('__scriptDataSideEffect' in globalThis).toBe(false);
});
it.each([
'window.__DATA__ = fetch("https://example.invalid/");',
'window.__DATA__ = Function("return 42")();',
'window.__DATA__ = { get secret() { return fetch("https://example.invalid/"); } };',
'window.__DATA__ = (function(){ while(true){} return {}; })();',
'window.__DATA__ = (function(){ return {}; })(fetch("https://example.invalid/"));',
])('rejects unsupported expressions required by the target without executing them', async (source) => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
await expect(evaluateScriptData(source, '__DATA__')).rejects.toThrow(/Unsupported|ordinary/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it.each(['__proto__', 'constructor', 'prototype'])('rejects the unsafe property %s', async (key) => {
await expect(evaluateScriptData(`window.__DATA__={"${key}":{polluted:true}};`, '__DATA__')).rejects.toThrow('Unsafe script data property');
await expect(evaluateScriptData(`var value={};value["${key}"]={polluted:true};window.__DATA__=value;`, '__DATA__')).rejects.toThrow('Unsafe script data property');
expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false);
});
it.each(['window.__DATA__()', 'window[computed]', 'window.__DATA__; unrelated()', 'window.__proto__'])('rejects unsupported target paths: %s', async (target) => {
await expect(evaluateScriptData('window.__DATA__={};', target)).rejects.toThrow();
});
it('reports missing targets and unresolved variable dependencies', async () => {
await expect(evaluateScriptData('unrelated();', '__DATA__')).rejects.toThrow('target was not found');
await expect(evaluateScriptData('var data=unrelated();window.__DATA__=data;', '__DATA__')).rejects.toThrow('Unsupported script data expression');
});
it.each(['if(true){window.__DATA__.value=2;}', 'Object.assign(window.__DATA__,{value:2});', 'window.__DATA__.value++;', 'window.__DATA__[unknown()]={};'])(
'rejects unsupported code that could change extracted data: %s',
async (suffix) => {
await expect(evaluateScriptData(`window.__DATA__={value:1};${suffix}`, '__DATA__')).rejects.toThrow('Unsupported script data');
}
);
it.each(['if(true){window.__DATA__.newField=2;}', 'var ignored=Object.assign(window.__DATA__,{value:2});', 'window.ignored=Object.assign(window.__DATA__,{value:2});'])(
'rejects mutations hidden in new properties and unused assignments: %s',
async (suffix) => {
await expect(evaluateScriptData(`window.__DATA__={};${suffix}`, '__DATA__')).rejects.toThrow('Unsupported script data');
}
);
it('rejects cyclic data and excessively large sources or sparse arrays', async () => {
await expect(evaluateScriptData('var a={};a.self=a;window.__DATA__=a;', '__DATA__')).rejects.toThrow('Cyclic script data');
await expect(evaluateScriptData(' '.repeat(2_000_001), '__DATA__')).rejects.toThrow('size limit');
await expect(evaluateScriptData('window.__DATA__=(function(){var a=[];a[100000]={};return a})();', '__DATA__')).rejects.toThrow('bounded numeric indexes');
});
});
describe('serialized callback data', () => {
it('extracts a DWR callback argument without invoking the callback', async () => {
const callback = vi.fn();
vi.stubGlobal('dwr', { engine: { _remoteHandleCallback: callback } });
const source = '(function(){if(!window.dwr)return;var s0=[],s1={};s1.post={title:"Entry"};s0[0]=s1;dwr.engine._remoteHandleCallback("493053","0",s0);})();';
await expect(evaluateScriptCallback(source, 'dwr.engine._remoteHandleCallback')).resolves.toEqual([{ post: { title: 'Entry' } }]);
expect(callback).not.toHaveBeenCalled();
});
it('supports empty public DWR responses and explicit argument positions', async () => {
const source = '//#DWR-INSERT\n//#DWR-REPLY\ndwr.engine._remoteHandleCallback("493053","0",[]);';
await expect(evaluateScriptCallback(source, 'window.dwr.engine._remoteHandleCallback')).resolves.toEqual([]);
await expect(evaluateScriptCallback(source, 'dwr.engine._remoteHandleCallback', 0)).resolves.toBe('493053');
});
it('evaluates all callback arguments in order and does not keep an earlier successful capture after an invalid one', async () => {
const source = 'var a={value:1};dwr.engine._remoteHandleCallback(a.value=2,"0",a);';
await expect(evaluateScriptCallback(source, 'dwr.engine._remoteHandleCallback')).resolves.toEqual({ value: 2 });
await expect(evaluateScriptCallback(`${source}dwr.engine._remoteHandleCallback("0","0",unknown());`, 'dwr.engine._remoteHandleCallback')).rejects.toThrow();
});
it('rejects missing callbacks and external calls used as the data argument', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
await expect(evaluateScriptCallback('unrelated();', 'dwr.engine._remoteHandleCallback')).rejects.toThrow('callback was not found');
await expect(evaluateScriptCallback('dwr.engine._remoteHandleCallback("0","0",fetch("https://example.invalid/"));', 'dwr.engine._remoteHandleCallback')).rejects.toThrow();
await expect(evaluateScriptCallback('', 'callback', -1)).rejects.toThrow('non-negative integer');
await expect(evaluateScriptCallback('dwr["engine._remoteHandleCallback"]("0","0",[]);', 'dwr.engine._remoteHandleCallback')).rejects.toThrow('callback was not found');
expect(fetchSpy).not.toHaveBeenCalled();
});
});