|
| 1 | +import assert from 'node:assert/strict' |
| 2 | +import { spawn, spawnSync } from 'node:child_process' |
| 3 | +import { once } from 'node:events' |
| 4 | +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' |
| 5 | +import http from 'node:http' |
| 6 | +import https from 'node:https' |
| 7 | +import { createRequire } from 'node:module' |
| 8 | +import { tmpdir } from 'node:os' |
| 9 | +import { join } from 'node:path' |
| 10 | +import test from 'node:test' |
| 11 | +import vm from 'node:vm' |
| 12 | +import { Worker } from 'node:worker_threads' |
| 13 | + |
| 14 | +const dshRoot = process.env.DSH_TEST_ROOT ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh' |
| 15 | +const requireDsh = createRequire(join(dshRoot, 'package.json')) |
| 16 | +const pdfPath = join(requireDsh.resolve('@deepseek-ai/dsh-client-ui-sidebar-documentpreview/package.json'), '../lib/client.pdf.js') |
| 17 | +const pdf = await readFile(pdfPath, 'utf8') |
| 18 | +const removeIterator = ` |
| 19 | + const prototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())); |
| 20 | + for (const name of ['map', 'filter', 'take', 'drop', 'flatMap', 'reduce', 'toArray', 'forEach', 'some', 'every', 'find', 'join']) delete prototype[name]; |
| 21 | + delete globalThis.Iterator; |
| 22 | +` |
| 23 | +const checkIterator = ` |
| 24 | + JSON.stringify(Iterator.from([1, 2, 3]).filter(x => x > 1).map(x => x * 2).toArray()) === '[4,6]' |
| 25 | + && new Map([[1, 'one']]).values().some(x => x === 'one') |
| 26 | +` |
| 27 | + |
| 28 | +test('PDF page initializes Iterator helpers before registering its module', () => { |
| 29 | + let registered = false |
| 30 | + const context = vm.createContext({ window: { __ModuleLoader__: { load() { registered = true } } } }) |
| 31 | + vm.runInContext(removeIterator, context) |
| 32 | + vm.runInContext(pdf, context, { timeout: 5000 }) |
| 33 | + assert.ok(registered) |
| 34 | + assert.equal(vm.runInContext(checkIterator, context), true) |
| 35 | +}) |
| 36 | + |
| 37 | +test('embedded PDF worker initializes its own Iterator helpers', async t => { |
| 38 | + const declaration = pdf.split('\n').find(line => line.trimStart().startsWith('var _dsh_pdf_worker_default = ')) |
| 39 | + assert.ok(declaration, 'PDF worker declaration must exist') |
| 40 | + const source = vm.runInNewContext(`${declaration}\n_dsh_pdf_worker_default`) |
| 41 | + const worker = new Worker(` |
| 42 | + const { parentPort, workerData } = require('node:worker_threads'); |
| 43 | + (async () => { |
| 44 | + ${removeIterator} |
| 45 | + const pdf = await import('data:text/javascript;base64,' + Buffer.from(workerData).toString('base64')); |
| 46 | + parentPort.postMessage({ ok: (${checkIterator}) && typeof pdf.WorkerMessageHandler === 'function' }); |
| 47 | + })().catch(error => { throw error }); |
| 48 | + `, { eval: true, workerData: source }) |
| 49 | + t.after(() => worker.terminate()) |
| 50 | + const [result] = await once(worker, 'message') |
| 51 | + assert.equal(result.ok, true) |
| 52 | +}) |
| 53 | + |
| 54 | +test('Caddy forwards valid settings requests and rejects cross-site API requests', { timeout: 60000 }, async t => { |
| 55 | + const caddy = process.env.CADDY_BINARY ?? 'caddy' |
| 56 | + const directory = await mkdtemp(join(tmpdir(), 'dsh-proxy-test-')) |
| 57 | + t.after(() => rm(directory, { recursive: true, force: true })) |
| 58 | + let forwarded = 0 |
| 59 | + const backend = http.createServer(async (request, response) => { |
| 60 | + forwarded++ |
| 61 | + let body = '' |
| 62 | + for await (const chunk of request) body += chunk |
| 63 | + response.setHeader('Content-Type', 'application/json') |
| 64 | + response.end(JSON.stringify({ ok: true, headers: request.headers, body: body ? JSON.parse(body) : null })) |
| 65 | + }) |
| 66 | + backend.listen(0, '127.0.0.1') |
| 67 | + await once(backend, 'listening') |
| 68 | + t.after(() => new Promise(resolve => backend.close(resolve))) |
| 69 | + const backendPort = backend.address().port |
| 70 | + const reserve = http.createServer() |
| 71 | + reserve.listen(0, '127.0.0.1') |
| 72 | + await once(reserve, 'listening') |
| 73 | + const port = reserve.address().port |
| 74 | + await new Promise(resolve => reserve.close(resolve)) |
| 75 | + const password = 'dsh-test-password' |
| 76 | + const hash = spawnSync(caddy, ['hash-password', '--algorithm', 'argon2id', '--plaintext', password], { encoding: 'utf8' }) |
| 77 | + assert.equal(hash.status, 0, hash.error?.message ?? hash.stderr) |
| 78 | + const config = (await readFile(process.env.DSH_CADDYFILE ?? '/etc/caddy/Caddyfile', 'utf8')) |
| 79 | + .replaceAll(':8443', `:${port}`).replaceAll('127.0.0.1:3080', `127.0.0.1:${backendPort}`) |
| 80 | + const configPath = join(directory, 'Caddyfile') |
| 81 | + await writeFile(configPath, config) |
| 82 | + const processCaddy = spawn(caddy, ['run', '--config', configPath, '--adapter', 'caddyfile'], { |
| 83 | + env: { ...process.env, CADDY_ACCESS_HOST: '127.0.0.1', DSH_AUTH_USERNAME: 'test', DSH_AUTH_PASSWORD_HASH: hash.stdout.trim(), XDG_DATA_HOME: directory, XDG_CONFIG_HOME: directory }, |
| 84 | + stdio: ['ignore', 'ignore', 'pipe'], |
| 85 | + }) |
| 86 | + let logs = '' |
| 87 | + processCaddy.stderr.on('data', chunk => { logs += chunk }) |
| 88 | + const exited = once(processCaddy, 'exit') |
| 89 | + t.after(async () => { processCaddy.kill('SIGTERM'); await exited }) |
| 90 | + const origin = `https://127.0.0.1:${port}` |
| 91 | + const request = (pathname, headers = {}, authenticated = true) => new Promise((resolve, reject) => { |
| 92 | + const req = https.request(`${origin}${pathname}`, { |
| 93 | + method: 'POST', rejectUnauthorized: false, servername: '', |
| 94 | + headers: { 'Content-Type': 'application/json', Origin: origin, ...(authenticated ? { Authorization: `Basic ${Buffer.from(`test:${password}`).toString('base64')}` } : {}), ...headers }, |
| 95 | + }, response => { |
| 96 | + let body = '' |
| 97 | + response.on('data', chunk => { body += chunk }) |
| 98 | + response.on('end', () => resolve({ status: response.statusCode, body })) |
| 99 | + }) |
| 100 | + req.setTimeout(3000, () => req.destroy(new Error('proxy request timed out'))) |
| 101 | + req.on('error', reject) |
| 102 | + req.end(JSON.stringify({ value: 'test' })) |
| 103 | + }) |
| 104 | + for (let attempt = 0; ; attempt++) { |
| 105 | + try { await request('/'); break } catch (error) { |
| 106 | + if (attempt === 100 || processCaddy.exitCode !== null) throw new Error(logs, { cause: error }) |
| 107 | + await new Promise(resolve => setTimeout(resolve, 100)) |
| 108 | + } |
| 109 | + } |
| 110 | + for (const endpoint of ['settings/describe', 'settings/update', 'settings/replace', 'settings/mutate', 'credentials/describe', 'credentials/set', 'credentials/unset', 'llm/discoverModels']) { |
| 111 | + const response = await request(`/api/${endpoint}`) |
| 112 | + assert.equal(response.status, 200, endpoint) |
| 113 | + const data = JSON.parse(response.body) |
| 114 | + assert.equal(data.ok, true) |
| 115 | + assert.equal(data.headers.host, `127.0.0.1:${backendPort}`) |
| 116 | + assert.equal(data.headers.origin, undefined) |
| 117 | + assert.equal(data.headers.authorization, undefined) |
| 118 | + assert.equal(data.body.value, 'test') |
| 119 | + } |
| 120 | + const beforeRejected = forwarded |
| 121 | + for (const pathname of ['/api', '/api/settings/update', '/api/credentials/set', '/api/remote.mux']) { |
| 122 | + for (const headers of [{ Origin: 'https://attacker.invalid' }, { Origin: 'null' }, { Origin: `http://127.0.0.1:${port}` }, { Origin: 'https://127.0.0.1:1' }, { 'Sec-Fetch-Site': 'cross-site' }, { Origin: '', 'Sec-Fetch-Site': 'cross-site' }]) { |
| 123 | + assert.equal((await request(pathname, headers)).status, 403) |
| 124 | + } |
| 125 | + } |
| 126 | + // Caddy can return an empty 200 for unmatched hosts; no request may reach DSH. |
| 127 | + assert.equal((await request('/api/settings/update', { Host: 'attacker.invalid', Origin: 'https://attacker.invalid' })).body, '') |
| 128 | + assert.equal((await request('/api/settings/describe', {}, false)).status, 401) |
| 129 | + assert.equal(forwarded, beforeRejected, 'rejected requests must never reach DSH') |
| 130 | +}) |
0 commit comments