mirror of
https://github.com/purocean/yn.git
synced 2026-10-16 07:26:07 +08:00
Merge pull request #535 from purocean/fix/api-origin-json-settings
fix: enforce API origin and JSON boundaries without exposing JWT key
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
# Security Policy / 安全政策
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report suspected security vulnerabilities privately to [yank-note@hotmail.com](mailto:yank-note@hotmail.com). Please coordinate disclosure before including vulnerability details in a public issue or pull request.
|
||||
|
||||
Please include:
|
||||
|
||||
- A description of the issue, its impact, and any conditions required to exploit it.
|
||||
- The affected Yank Note version, operating system, and relevant configuration or extensions.
|
||||
- Minimal, safe steps to reproduce, with a proof of concept or redacted logs if helpful.
|
||||
- Any proposed mitigation or fix, and your preferred contact and credit details.
|
||||
|
||||
Test only on systems and data you own or have permission to use. Avoid destructive testing, disruption, or accessing other people's data. Use synthetic data and remove passwords, tokens, personal information, and other secrets from reports.
|
||||
|
||||
## Coordinated disclosure
|
||||
|
||||
Please give the maintainer a reasonable opportunity to investigate and prepare a fix or mitigation. Share any planned disclosure date and discuss timing together. Where possible, avoid publishing actionable exploit details or proof-of-concept code while the issue remains unresolved. Any public credit should be agreed with the reporter.
|
||||
|
||||
## 报告安全漏洞
|
||||
|
||||
如发现疑似安全漏洞,请先通过 [yank-note@hotmail.com](mailto:yank-note@hotmail.com) 私下报告。请先协调披露,再在公开 Issue 或 Pull Request 中分享漏洞细节。
|
||||
|
||||
请尽量提供:
|
||||
|
||||
- 问题描述、影响及利用所需的前提条件。
|
||||
- 受影响的 Yank Note 版本、操作系统,以及相关配置或扩展。
|
||||
- 最小且安全的复现步骤,必要时附上验证代码或已脱敏的日志。
|
||||
- 可行的缓解或修复建议,以及希望使用的联系方式和署名方式。
|
||||
|
||||
请仅在自己拥有或获准使用的系统和数据上测试,避免破坏性操作、影响服务或访问他人数据。请使用模拟数据,并移除报告中的密码、令牌、个人信息及其他敏感内容。
|
||||
|
||||
## 协调披露
|
||||
|
||||
请给维护者合理的时间调查问题并准备修复或缓解措施。如有计划公开披露的日期,请提前沟通并共同讨论时间安排。在问题尚未解决时,请尽量避免公开可直接用于攻击的细节或验证代码。公开致谢及署名应与报告者协商一致。
|
||||
@@ -37,3 +37,43 @@ The node-pty module cannot be simply installed successfully, it can be operated
|
||||
`npm run dev`
|
||||
|
||||
|
||||
|
||||
## HTTP API request boundary
|
||||
|
||||
API requests are checked before body parsing or route effects. The server
|
||||
validates Host independently of Origin and forwarding headers, rejects explicit
|
||||
cross-origin, opaque/null, malformed or contradictory browser provenance, and
|
||||
accepts its same-origin browser and internal Electron protocol requests.
|
||||
Requests without Origin, Referer or Sec-Fetch-Site retain the existing native
|
||||
API/MCP authentication behavior. This is compatibility, not proof of a trusted
|
||||
caller: native programs and older clients can omit or forge browser headers.
|
||||
No MCP configuration or token issuance changes are required.
|
||||
|
||||
Body-bearing API requests require `application/json` (parameters such as charset
|
||||
are allowed), with these existing-format exceptions:
|
||||
- POST `/api/attachment`: multipart form upload is also accepted.
|
||||
- POST `/api/tmp-file` and `/api/user-file`: raw `text/plain` is also accepted.
|
||||
- `/api/proxy-fetch/…`: the original stream is forwarded without MIME filtering.
|
||||
|
||||
All exceptions still receive the source and permission checks. Bodyless
|
||||
GET/DELETE and existing query-only extension actions need no JSON body. Known
|
||||
body-driven mutation routes reject unsupported methods before reaching handlers.
|
||||
Non-API resources are not subject to the JSON requirement.
|
||||
|
||||
The executable embed document additionally requires trusted provenance even for
|
||||
navigation, including its static/encoded path aliases. Root-page navigation and
|
||||
same-origin/internal preview frames and windows remain supported. Socket.IO
|
||||
polling, upgrades and pre-PTY handling independently require trusted provenance
|
||||
and a loopback peer; native API compatibility does not exempt terminal requests.
|
||||
The `yank-note:` renderer uses WebSocket directly; browser transport defaults stay
|
||||
unchanged.
|
||||
|
||||
Settings JSON and JavaScript responses remove only `server.jwt-secret` from a
|
||||
copy. Cached and persisted settings and the signing key are not rotated.
|
||||
These checks do not solve same-origin XSS, native same-user access or the existing
|
||||
loopback-admin risk of an externally exposed reverse proxy. Reverse-proxy access
|
||||
needs separate authentication and compatible Host/source configuration.
|
||||
|
||||
Development proxy adaptation is intentionally outside this change. The existing
|
||||
Vite frontend port/forwarded Host and source may be rejected by the backend;
|
||||
this change does not add development-origin exceptions or modify Vite.
|
||||
|
||||
@@ -33,7 +33,7 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const socketEmitter = new EventEmitter()
|
||||
const socketEmitter = Object.assign(new EventEmitter(), { engine: { use: vi.fn() } })
|
||||
const httpServer = { listen: vi.fn(), on: vi.fn() }
|
||||
|
||||
return {
|
||||
@@ -78,6 +78,7 @@ const mocks = vi.hoisted(() => {
|
||||
fsEnsureFile: vi.fn(),
|
||||
fsExistsSync: vi.fn(),
|
||||
fsReadFile: vi.fn(),
|
||||
fsRealpathSync: vi.fn(),
|
||||
fsReaddir: vi.fn(),
|
||||
fsStat: vi.fn(),
|
||||
fsStatSync: vi.fn(),
|
||||
@@ -136,6 +137,7 @@ vi.mock('fs-extra', () => {
|
||||
ensureFile: (...args: any[]) => mocks.fsEnsureFile(...args),
|
||||
existsSync: (...args: any[]) => mocks.fsExistsSync(...args),
|
||||
readFile,
|
||||
realpathSync: (...args: any[]) => mocks.fsRealpathSync(...args),
|
||||
readdir: (...args: any[]) => mocks.fsReaddir(...args),
|
||||
stat: (...args: any[]) => mocks.fsStat(...args),
|
||||
statSync: (...args: any[]) => mocks.fsStatSync(...args),
|
||||
@@ -259,7 +261,6 @@ function createCtx (overrides: Record<string, any> = {}) {
|
||||
const headers: Record<string, any> = {}
|
||||
const ctx: any = {
|
||||
body: undefined,
|
||||
headers: overrides.headers || {},
|
||||
method: overrides.method || 'GET',
|
||||
originalUrl: overrides.originalUrl || overrides.path || '/',
|
||||
path: overrides.path || '/',
|
||||
@@ -290,7 +291,8 @@ function createCtx (overrides: Record<string, any> = {}) {
|
||||
get responseHeaders () {
|
||||
return headers
|
||||
},
|
||||
...overrides
|
||||
...overrides,
|
||||
headers: { ...(['POST', 'PUT', 'PATCH'].includes(overrides.method) ? { 'content-type': 'application/json' } : {}), host: 'localhost:3099', origin: 'http://localhost:3099', ...overrides.headers }
|
||||
}
|
||||
|
||||
return ctx
|
||||
@@ -315,6 +317,7 @@ describe('server index module', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.doUnmock('node-pty')
|
||||
mocks.actions.clear()
|
||||
mocks.socketEmitter.removeAllListeners()
|
||||
mocks.MockKoa.instances.length = 0
|
||||
mocks.disableServer = true
|
||||
mocks.configGet.mockImplementation((_key: string, defaultValue: any) => defaultValue)
|
||||
@@ -346,6 +349,7 @@ describe('server index module', () => {
|
||||
mocks.fsCreateReadStream.mockReturnValue('fs-stream')
|
||||
mocks.fsEnsureFile.mockResolvedValue(undefined)
|
||||
mocks.fsExistsSync.mockReturnValue(false)
|
||||
mocks.fsRealpathSync.mockImplementation((filePath: string) => filePath)
|
||||
mocks.fsReadFile.mockResolvedValue(Buffer.from('disk-content'))
|
||||
mocks.fsReaddir.mockResolvedValue([])
|
||||
mocks.fsStat.mockResolvedValue({ size: 8 })
|
||||
@@ -380,7 +384,7 @@ describe('server index module', () => {
|
||||
jsonLimit: '50mb',
|
||||
formidable: { maxFieldsSize: 268435456 }
|
||||
}))
|
||||
expect(mocks.MockKoa.instances[0].middlewares).toHaveLength(21)
|
||||
expect(mocks.MockKoa.instances[0].middlewares).toHaveLength(23)
|
||||
})
|
||||
|
||||
test('enforces bearer permissions and allow lists before route handlers', async () => {
|
||||
@@ -414,6 +418,213 @@ describe('server index module', () => {
|
||||
expect(invalid.body).toEqual({ status: 'error', message: 'bad token', data: null })
|
||||
})
|
||||
|
||||
test.each([
|
||||
'/api/file', '/api/tree', '/api/settings', '/api/settings/js', '/api/run',
|
||||
'/api/attachment', '/api/plugins', '/api/extensions', '/api/mcp', '/api/future-route'
|
||||
])('rejects untrusted origins before any handler: %s', async (path) => {
|
||||
const ctx = await runRequest({ path, headers: { origin: 'https://attacker.example' } })
|
||||
expect(ctx.status).toBe(403)
|
||||
expect(mocks.configGetAll).not.toHaveBeenCalled()
|
||||
expect(mocks.fileRead).not.toHaveBeenCalled()
|
||||
expect(mocks.jwtVerify).not.toHaveBeenCalled()
|
||||
expect(mocks.handleMCPRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('checks source even for token-bearing requests and rejects DNS rebinding', async () => {
|
||||
for (const headers of [
|
||||
{ origin: 'null' },
|
||||
{ origin: undefined, 'sec-fetch-site': 'none' },
|
||||
{ origin: undefined, referer: 'http://localhost:3099/', 'sec-fetch-site': 'cross-site' },
|
||||
{ host: 'rebound.example:3099', origin: 'http://rebound.example:3099', 'sec-fetch-site': 'same-origin' },
|
||||
{ origin: 'http://localhost:3100' },
|
||||
{ origin: 'https://localhost:3099' }
|
||||
]) {
|
||||
const ctx = await runRequest({ path: '/api/settings', headers: { ...headers, authorization: 'Bearer admin' } })
|
||||
expect(ctx.status).toBe(403)
|
||||
}
|
||||
expect(mocks.jwtVerify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('preserves same-origin bootstrap GET and trusted Electron protocol requests', async () => {
|
||||
for (const headers of [
|
||||
{ origin: undefined, 'sec-fetch-site': 'same-origin' },
|
||||
{ origin: undefined, referer: 'http://localhost:3099/' }
|
||||
]) {
|
||||
expect((await runRequest({ path: '/api/settings/js', headers })).status).toBe(200)
|
||||
}
|
||||
expect((await runRequest({ path: '/api/settings', req: { _protocol: true }, headers: { origin: undefined } })).status).toBe(200)
|
||||
expect((await runRequest({ path: '/api/settings', req: { _protocol: true }, headers: { origin: 'null' } })).status).toBe(403)
|
||||
})
|
||||
|
||||
test('preserves root navigation and native MCP while rejecting rebinding', async () => {
|
||||
mocks.fsExistsSync.mockReturnValueOnce(true)
|
||||
const home = await runRequest({ path: '/', headers: { origin: undefined, 'sec-fetch-site': 'none', 'sec-fetch-dest': 'document' } })
|
||||
expect(home.status).not.toBe(403)
|
||||
expect((await runRequest({ path: '/', headers: { host: 'evil.example:3099', origin: undefined } })).status).toBe(403)
|
||||
const native = await runRequest({ path: '/api/mcp/message', method: 'POST', headers: { origin: undefined, 'sec-fetch-mode': 'cors' }, body: { jsonrpc: '2.0', method: 'initialize', id: 1 } })
|
||||
expect(native.status).toBe(200)
|
||||
expect(native.req.jwt).toEqual({ role: 'admin' })
|
||||
expect(mocks.handleMCPRequest).toHaveBeenCalled()
|
||||
mocks.handleMCPRequest.mockClear()
|
||||
mocks.jwtVerify.mockReturnValue({ role: 'guest' })
|
||||
const guest = await runRequest({ path: '/api/mcp/message', method: 'POST', ip: '203.0.113.8', headers: { origin: undefined, authorization: 'Bearer guest' } })
|
||||
expect(guest.body.status).toBe('error')
|
||||
expect(mocks.handleMCPRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('guards the actual executable embed file including static and encoded aliases', async () => {
|
||||
mocks.fsExistsSync.mockReturnValue(true)
|
||||
mocks.fsStatSync.mockImplementation((filePath: string) => ({ isDirectory: () => filePath.toLowerCase() === '/static/embed', mtime: new Date(), size: 9 }))
|
||||
for (const path of ['/embed/', '/embed/index.html', '/static/embed/', '/static/embed/index.html', '/%65mbed/index.html', '/static/./embed/index.html', '/EMBED/index.html', '/embed/INDEX.HTML', '/EMBED/']) {
|
||||
for (const headers of [
|
||||
{ origin: undefined },
|
||||
{ origin: undefined, 'sec-fetch-site': 'cross-site', 'sec-fetch-dest': 'document' },
|
||||
{ origin: undefined, 'sec-fetch-site': 'none', 'sec-fetch-dest': 'document' }
|
||||
]) {
|
||||
mocks.fsReadFile.mockClear()
|
||||
expect((await runRequest({ path, headers })).status).toBe(403)
|
||||
expect(mocks.fsReadFile).not.toHaveBeenCalled()
|
||||
}
|
||||
expect((await runRequest({ path })).status).toBe(200)
|
||||
expect((await runRequest({ path, headers: { origin: undefined }, req: { _protocol: true } })).status).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
test('guards executable embed filesystem identity aliases', async () => {
|
||||
mocks.fsExistsSync.mockReturnValue(true)
|
||||
mocks.fsStatSync.mockReturnValue({ isDirectory: () => false, mtime: new Date(), size: 9, dev: 1, ino: 42 })
|
||||
for (const path of ['/embed/index.html.', '/embed/index.html::$DATA', '/embed/INDEX~1.HTM', '/alias.html']) {
|
||||
expect((await runRequest({ path, headers: { origin: undefined } })).status).toBe(403)
|
||||
}
|
||||
expect(mocks.fsReadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('preserves distinct static files and fails closed if embed aliases cannot be classified', async () => {
|
||||
mocks.fsExistsSync.mockReturnValue(true)
|
||||
mocks.fsStatSync.mockImplementation((filePath: string) => ({ isDirectory: () => false, mtime: new Date(), size: 9, dev: 1, ino: filePath === '/static/embed/index.html' ? 42 : 43 }))
|
||||
expect((await runRequest({ path: '/index.html', headers: { origin: undefined } })).status).toBe(200)
|
||||
mocks.fsRealpathSync.mockImplementation((filePath: string) => filePath === '/static/alias.html' ? '/static/embed/index.html' : filePath)
|
||||
expect((await runRequest({ path: '/alias.html', headers: { origin: undefined } })).status).toBe(403)
|
||||
mocks.fsStatSync.mockReturnValue({ isDirectory: () => false, mtime: new Date(), size: 9, dev: 0, ino: 0 })
|
||||
mocks.fsRealpathSync.mockImplementation((filePath: string) => filePath === '/static/alias.html' ? '/static/embed/index.html' : filePath)
|
||||
expect((await runRequest({ path: '/alias.html', headers: { origin: undefined } })).status).toBe(403)
|
||||
expect((await runRequest({ path: '/index.html', headers: { origin: undefined } })).status).toBe(200)
|
||||
mocks.fsRealpathSync.mockImplementation(() => { throw new Error('Unavailable') })
|
||||
expect((await runRequest({ path: '/alias.html', headers: { origin: undefined } })).status).toBe(403)
|
||||
})
|
||||
|
||||
test.each(['application/x-www-form-urlencoded', 'multipart/form-data; boundary=test', 'text/plain'])('rejects non-JSON %s before API handlers', async contentType => {
|
||||
const ctx = await runRequest({ path: '/api/run', method: 'POST', headers: { 'content-type': contentType }, body: { cmd: 'dummy', code: 'dummy' } })
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(mocks.runCode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test.each(['application/x-www-form-urlencoded', 'multipart/form-data; boundary=test', 'text/plain', 'application/json'])('rejects hostile POST %s before parser and any effects', async contentType => {
|
||||
const { default: server } = await loadServer()
|
||||
const { callback } = server(3099)
|
||||
const ctx = createCtx({ path: '/api/run', method: 'POST', headers: { origin: 'https://evil.example', 'content-type': contentType } })
|
||||
const parser = vi.fn()
|
||||
mocks.MockKoa.instances[0].middlewares[4] = parser
|
||||
await callback(ctx)
|
||||
expect(ctx.status).toBe(403)
|
||||
expect(parser).not.toHaveBeenCalled()
|
||||
expect(mocks.runCode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test.skipIf(!process.env.YN_TEST_CHROMIUM)('real Chromium rejects external executable embeds and preserves same-origin frames/windows', async () => {
|
||||
const { createServer } = await vi.importActual<typeof import('http')>('http')
|
||||
const { readFileSync } = await vi.importActual<typeof import('fs')>('fs')
|
||||
const { createRequire } = await import('module')
|
||||
const require = createRequire(import.meta.url)
|
||||
const { chromium } = require(process.env.YN_PLAYWRIGHT_MODULE || 'playwright')
|
||||
const embed = readFileSync('src/renderer/embed/index.html', 'utf8')
|
||||
let boundary: Function
|
||||
let bodyGuard: Function
|
||||
let serveStatic: Function
|
||||
let effects = 0
|
||||
const backend = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, 'http://' + req.headers.host)
|
||||
const ctx = createCtx({ path: url.pathname, method: req.method, headers: { origin: undefined, 'content-type': undefined, ...req.headers } })
|
||||
await boundary(ctx, () => bodyGuard(ctx, async () => {
|
||||
if (url.pathname === '/api/dummy') {
|
||||
effects++
|
||||
ctx.body = 'dummy only'
|
||||
} else {
|
||||
await serveStatic(ctx, async () => {})
|
||||
}
|
||||
}))
|
||||
res.writeHead(ctx.status, { 'Content-Type': ctx.path.startsWith('/api/') ? 'text/plain' : 'text/html' })
|
||||
res.end(typeof ctx.body === 'string' || Buffer.isBuffer(ctx.body) ? ctx.body : JSON.stringify(ctx.body))
|
||||
})
|
||||
await new Promise<void>(resolve => backend.listen(0, '127.0.0.1', resolve))
|
||||
const port = (backend.address() as import('net').AddressInfo).port
|
||||
const { default: server } = await loadServer()
|
||||
server(port)
|
||||
const middlewares = mocks.MockKoa.instances[mocks.MockKoa.instances.length - 1].middlewares
|
||||
boundary = middlewares[0]
|
||||
bodyGuard = middlewares[2]
|
||||
serveStatic = middlewares[middlewares.length - 1]
|
||||
mocks.fsExistsSync.mockReturnValue(true)
|
||||
mocks.fsStatSync.mockImplementation((filePath: string) => ({ isDirectory: () => ['/static', '/static/embed'].includes(filePath), mtime: new Date(), size: 9 }))
|
||||
mocks.fsReadFile.mockImplementation((filePath: string) => Buffer.from(filePath === '/static/embed/index.html' ? embed : '<!doctype html><title>Safe entry</title>'))
|
||||
const origin = `http://127.0.0.1:${port}`
|
||||
const payload = '<script>fetch("/api/dummy", {method:"POST", headers:{"content-type":"application/json"}, body:"{}"})</script>'
|
||||
const embedUrls = ['/embed/?html=' + encodeURIComponent(payload), '/embed/index.html#html=' + encodeURIComponent(payload)]
|
||||
const attacker = createServer((_req, res) => res.end('<a id="attack" href="' + origin + embedUrls[0] + '">Open</a>'))
|
||||
await new Promise<void>(resolve => attacker.listen(0, '127.0.0.1', resolve))
|
||||
const attackerOrigin = `http://127.0.0.1:${(attacker.address() as import('net').AddressInfo).port}`
|
||||
let browser: any
|
||||
try {
|
||||
browser = await chromium.launch({ executablePath: process.env.YN_TEST_CHROMIUM, headless: true, args: ['--no-sandbox'] })
|
||||
const page = await browser.newPage()
|
||||
for (const url of embedUrls) {
|
||||
expect((await page.goto(origin + url)).status()).toBe(403)
|
||||
}
|
||||
await page.goto(attackerOrigin)
|
||||
await Promise.all([page.waitForNavigation(), page.click('#attack')])
|
||||
expect(await page.textContent('body')).toContain('Forbidden request origin')
|
||||
expect(effects).toBe(0)
|
||||
await page.goto(origin)
|
||||
const frameRequest = page.waitForResponse((response: any) => response.url() === origin + '/api/dummy')
|
||||
await page.evaluate((url: string) => {
|
||||
const frame = document.createElement('iframe')
|
||||
frame.src = url
|
||||
document.body.appendChild(frame)
|
||||
}, embedUrls[0])
|
||||
expect((await frameRequest).status()).toBe(200)
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
await page.evaluate((url: string) => window.open(url), embedUrls[1])
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState()
|
||||
await expect.poll(() => effects).toBe(2)
|
||||
expect(await popup.textContent('body')).not.toContain('Forbidden request origin')
|
||||
// Node's native fetch adds Sec-Fetch-Mode: cors but no browser source.
|
||||
const native = await fetch(origin + '/api/dummy')
|
||||
expect(native.status).toBe(200)
|
||||
expect(effects).toBe(3)
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await Promise.all([new Promise<void>(resolve => backend.close(() => resolve())), new Promise<void>(resolve => attacker.close(() => resolve()))])
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
test('redacts only the signing key from both admin settings formats without changing the cache', async () => {
|
||||
const saved = Object.freeze({
|
||||
'server.jwt-secret': 'signing-secret', 'other-secret': 'preserved', 'api-token': 'preserved',
|
||||
repositories: { main: '/repo' }, license: 'license', extensions: ['one'], theme: 'plain.css'
|
||||
})
|
||||
mocks.configGetAll.mockReturnValue(saved)
|
||||
const expected = { ...saved }
|
||||
delete expected['server.jwt-secret']
|
||||
const json = await runRequest({ path: '/api/settings' })
|
||||
const js = await runRequest({ path: '/api/settings/js' })
|
||||
expect(json.body.data).toEqual(expected)
|
||||
expect(js.body).toBe('_INIT_SETTINGS = ' + JSON.stringify(expected))
|
||||
expect(saved['server.jwt-secret']).toBe('signing-secret')
|
||||
await runRequest({ path: '/api/settings', method: 'POST', body: { theme: 'changed.css' } })
|
||||
expect(mocks.configSetAll).toHaveBeenCalledWith({ ...saved, theme: 'changed.css' })
|
||||
})
|
||||
|
||||
test('handles file read, write, delete, rename, copy, history, tree, and watch routes', async () => {
|
||||
mocks.fileStat.mockResolvedValue({ size: 6, mtime: 2, birthtime: 1 })
|
||||
mocks.fileRead.mockResolvedValue(Buffer.from('hello'))
|
||||
@@ -468,9 +679,9 @@ describe('server index module', () => {
|
||||
|
||||
await runRequest({ path: '/api/history/list', query: { repo: 'main', path: '/a.md' } })
|
||||
await runRequest({ path: '/api/history/content', query: { repo: 'main', path: '/a.md', version: 'v1' } })
|
||||
await runRequest({ path: '/api/history/delete', body: { repo: 'main', path: '/a.md', version: 'v1' } })
|
||||
await runRequest({ path: '/api/history/comment', body: { repo: 'main', path: '/a.md', version: 'v1', msg: 'keep' } })
|
||||
await runRequest({ path: '/api/watch-file', body: { repo: 'main', path: '/a.md', options: { recursive: false } } })
|
||||
await runRequest({ method: 'POST', path: '/api/history/delete', body: { repo: 'main', path: '/a.md', version: 'v1' } })
|
||||
await runRequest({ method: 'POST', path: '/api/history/comment', body: { repo: 'main', path: '/a.md', version: 'v1', msg: 'keep' } })
|
||||
await runRequest({ method: 'POST', path: '/api/watch-file', body: { repo: 'main', path: '/a.md', options: { recursive: false } } })
|
||||
|
||||
expect(mocks.fileHistoryList).toHaveBeenCalledWith('main', '/a.md')
|
||||
expect(mocks.fileHistoryContent).toHaveBeenCalledWith('main', '/a.md', 'v1')
|
||||
@@ -684,7 +895,7 @@ describe('server index module', () => {
|
||||
body: { status: 'ok', data: ['github.css', 'plain.css', 'custom.css'] }
|
||||
})
|
||||
|
||||
mocks.configGet.mockReturnValueOnce('extension:foo/theme.css')
|
||||
mocks.configGet.mockImplementation((key: string, defaultValue: any) => key.startsWith('custom-css') ? 'extension:foo/theme.css' : defaultValue)
|
||||
mocks.extensionList.mockResolvedValueOnce([{ enabled: true, id: 'id:foo' }])
|
||||
const cssRedirect = await runRequest({ path: '/custom-css' })
|
||||
expect(cssRedirect.redirect).toHaveBeenCalledWith('/extensions/foo/theme.css')
|
||||
@@ -694,7 +905,7 @@ describe('server index module', () => {
|
||||
await runRequest({ method: 'POST', path: '/api/extensions/uninstall', query: { id: 'x' } })
|
||||
await runRequest({ method: 'POST', path: '/api/extensions/enable', query: { id: 'x' } })
|
||||
await runRequest({ method: 'POST', path: '/api/extensions/disable', query: { id: 'x' } })
|
||||
await runRequest({ method: 'POST', path: '/api/extensions/abort-installation' })
|
||||
await runRequest({ method: 'DELETE', path: '/api/extensions/abort-installation' })
|
||||
expect(mocks.extensionInstall).toHaveBeenCalledWith('x', 'https://e.test/x.zip')
|
||||
expect(mocks.extensionUninstall).toHaveBeenCalledWith('x')
|
||||
expect(mocks.extensionEnable).toHaveBeenCalledWith('x')
|
||||
@@ -717,14 +928,14 @@ describe('server index module', () => {
|
||||
const responseBody = new mocks.PassThrough()
|
||||
mocks.request.mockResolvedValueOnce({ body: responseBody, headers: { 'x-upstream': 'yes' }, statusCode: 202 })
|
||||
const proxy = await runRequest({
|
||||
headers: { host: 'local', 'x-proxy-timeout': '1000', 'x-proxy-max-redirections': '1', accept: 'text/plain' },
|
||||
headers: { host: 'localhost:3099', 'x-proxy-timeout': '1000', 'x-proxy-max-redirections': '1', accept: 'text/plain' },
|
||||
method: 'POST',
|
||||
originalUrl: '/api/proxy-fetch/https://example.test/data',
|
||||
path: '/api/proxy-fetch/https://example.test/data',
|
||||
req: new mocks.PassThrough()
|
||||
})
|
||||
expect(mocks.request).toHaveBeenCalledWith('https://example.test/data', expect.objectContaining({
|
||||
headers: { accept: 'text/plain' },
|
||||
headers: { accept: 'text/plain', origin: 'http://localhost:3099', 'content-type': 'application/json' },
|
||||
maxRedirections: 1,
|
||||
method: 'POST'
|
||||
}))
|
||||
@@ -770,7 +981,7 @@ describe('server index module', () => {
|
||||
body: { status: 'error', message: 'Forbidden', data: null }
|
||||
})
|
||||
|
||||
mocks.configGet.mockReturnValueOnce('missing.css')
|
||||
mocks.configGet.mockImplementation((key: string, defaultValue: any) => key.startsWith('custom-css') ? 'missing.css' : defaultValue)
|
||||
mocks.fsReadFile
|
||||
.mockRejectedValueOnce(new Error('missing custom css'))
|
||||
.mockResolvedValueOnce(Buffer.from('default css'))
|
||||
@@ -851,7 +1062,7 @@ describe('server index module', () => {
|
||||
const responseBody = new mocks.PassThrough()
|
||||
mocks.request.mockResolvedValueOnce({ body: responseBody, headers: {}, statusCode: 204 })
|
||||
await runRequest({
|
||||
headers: { 'x-proxy-url': 'http://proxy.test', host: 'local' },
|
||||
headers: { 'x-proxy-url': 'http://proxy.test', host: 'localhost:3099' },
|
||||
originalUrl: '/api/proxy-fetch/https://example.test/proxied',
|
||||
path: '/api/proxy-fetch/https://example.test/proxied'
|
||||
})
|
||||
@@ -902,14 +1113,14 @@ describe('server index module', () => {
|
||||
const { default: server } = await loadServer()
|
||||
const started = server(3999)
|
||||
expect(started.server).toBe(mocks.httpServer)
|
||||
expect(mocks.socketIo).toHaveBeenCalledWith(mocks.httpServer, { path: '/ws' })
|
||||
expect(mocks.socketIo).toHaveBeenCalledWith(mocks.httpServer, expect.objectContaining({ path: '/ws', allowRequest: expect.any(Function) }))
|
||||
expect(mocks.httpServer.listen).toHaveBeenCalledWith(3999, '127.0.0.1')
|
||||
|
||||
const remoteSocket = {
|
||||
client: { conn: { remoteAddress: '203.0.113.10' } },
|
||||
disconnect: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
handshake: { query: {} },
|
||||
handshake: { headers: { host: 'localhost:3999', origin: 'http://localhost:3999' }, query: {} },
|
||||
on: vi.fn()
|
||||
}
|
||||
mocks.socketEmitter.emit('connection', remoteSocket)
|
||||
@@ -919,7 +1130,7 @@ describe('server index module', () => {
|
||||
client: { conn: { remoteAddress: '127.0.0.1' } },
|
||||
disconnect: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
handshake: { query: {} },
|
||||
handshake: { headers: { host: 'localhost:3999', origin: 'http://localhost:3999' }, query: {} },
|
||||
on: vi.fn()
|
||||
}
|
||||
mocks.socketEmitter.emit('connection', localSocket)
|
||||
@@ -976,11 +1187,27 @@ describe('server index module', () => {
|
||||
client: { conn: { remoteAddress: '127.0.0.1' } },
|
||||
disconnect: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
handshake: { query: { cwd: '/repo', env: '{"TERM":"xterm"}' } },
|
||||
handshake: { headers: { host: 'localhost:3999', 'sec-fetch-site': 'same-origin' }, query: { cwd: '/repo', env: '{"TERM":"xterm"}' } },
|
||||
on: vi.fn((event: string, handler: Function) => {
|
||||
socketHandlers[event] = handler
|
||||
})
|
||||
}
|
||||
for (const origin of ['https://attacker.example', 'null', undefined]) {
|
||||
const blocked = { ...localSocket, handshake: { ...localSocket.handshake, headers: { host: 'localhost:3999', origin } } }
|
||||
mocks.socketEmitter.emit('connection', blocked)
|
||||
expect(pty.spawn).not.toHaveBeenCalled()
|
||||
}
|
||||
const allowRequest = mocks.socketIo.mock.calls.at(-1)![1].allowRequest
|
||||
const callback = vi.fn()
|
||||
allowRequest({ headers: { host: 'localhost:3999', origin: 'https://attacker.example' }, socket: { remoteAddress: '127.0.0.1' } }, callback)
|
||||
expect(callback).toHaveBeenCalledWith('Forbidden request origin', false)
|
||||
allowRequest({ headers: { host: 'localhost:3999', 'sec-fetch-site': 'same-origin' }, socket: { remoteAddress: '127.0.0.1' } }, callback)
|
||||
expect(callback).toHaveBeenLastCalledWith(null, true)
|
||||
allowRequest({ headers: { host: 'localhost:3999', 'sec-fetch-site': 'same-origin', upgrade: 'websocket' }, socket: { remoteAddress: '127.0.0.1' } }, callback)
|
||||
expect(callback).toHaveBeenLastCalledWith('Forbidden request origin', false)
|
||||
const engineGuard = mocks.socketEmitter.engine.use.mock.calls.at(-1)![0]
|
||||
engineGuard({ headers: { host: 'localhost:3999', origin: 'https://attacker.example' }, socket: { remoteAddress: '127.0.0.1' } }, {}, callback)
|
||||
expect(callback).toHaveBeenLastCalledWith(expect.any(Error))
|
||||
mocks.socketEmitter.emit('connection', localSocket)
|
||||
|
||||
expect(pty.spawn).toHaveBeenCalledWith('/bin/zsh', [], expect.objectContaining({
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
// @vitest-environment node
|
||||
import type { Context } from 'koa'
|
||||
import type { Server } from 'http'
|
||||
import type { AddressInfo } from 'net'
|
||||
import Koa from 'koa'
|
||||
import bodyParser from 'koa-body'
|
||||
import { checkApiRequestBody } from '../request-body'
|
||||
|
||||
async function check (path: string, method = 'POST', headers: Record<string, string> = {}) {
|
||||
const ctx = { path, method, headers, set: vi.fn() } as unknown as Context
|
||||
const next = vi.fn(async () => undefined)
|
||||
await checkApiRequestBody(ctx, next)
|
||||
return { ctx, next }
|
||||
}
|
||||
|
||||
describe('API request body policy', () => {
|
||||
test.each([
|
||||
'application/json',
|
||||
'application/json; charset=utf-8',
|
||||
'Application/JSON; Charset=UTF-8',
|
||||
])('accepts the JSON media type %s', async contentType => {
|
||||
const { next } = await check('/api/settings', 'POST', { 'content-type': contentType })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test.each([
|
||||
undefined,
|
||||
'',
|
||||
'text/plain',
|
||||
'text/json',
|
||||
'application/x-www-form-urlencoded',
|
||||
'multipart/form-data; boundary=example',
|
||||
'application/jsonp',
|
||||
'application/problem+json',
|
||||
'text/plain; application/json',
|
||||
'application/json, text/plain',
|
||||
])('rejects non-JSON API bodies before downstream parsing: %s', async contentType => {
|
||||
const headers: Record<string, string> = { 'content-length': '20' }
|
||||
if (contentType !== undefined) headers['content-type'] = contentType
|
||||
const { ctx, next } = await check('/api/settings', 'POST', headers)
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(ctx.body).toMatchObject({ status: 'error' })
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test.each(['POST', 'PUT', 'PATCH'])('requires JSON even for empty %s requests', async method => {
|
||||
const { ctx, next } = await check('/api/file', method, { 'content-length': '0' })
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test.each([
|
||||
['/api/file', 'GET'],
|
||||
['/api/file', 'DELETE'],
|
||||
['/api/tree', 'GET'],
|
||||
['/api/history/list', 'GET'],
|
||||
['/api/history/content', 'GET'],
|
||||
['/api/settings', 'GET'],
|
||||
['/api/tmp-file', 'GET'],
|
||||
['/api/tmp-file', 'DELETE'],
|
||||
['/api/user-file', 'GET'],
|
||||
['/api/user-file', 'DELETE'],
|
||||
['/api/attachment/main/image.png', 'GET'],
|
||||
])('preserves bodyless %s %s requests', async (path, method) => {
|
||||
const { next } = await check(path, method, { 'content-length': '0' })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test.each(['GET', 'HEAD', 'DELETE', 'OPTIONS'])('checks declared %s bodies too', async method => {
|
||||
const requestHeaders: Record<string, string>[] = [
|
||||
{ 'content-type': 'text/plain', 'content-length': '5' },
|
||||
{ 'transfer-encoding': 'chunked' },
|
||||
{ 'content-length': '5' },
|
||||
]
|
||||
for (const headers of requestHeaders) {
|
||||
const { ctx, next } = await check('/api/file', method, headers)
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(['/api/tmp-file', '/api/user-file'])('preserves exact raw-text upload %s', async path => {
|
||||
const { next } = await check(path, 'POST', { 'content-type': 'text/plain;charset=UTF-8' })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test.each(['/api/tmp-file/extra', '/api/tmp-file-other', '/api/user-file/extra', '/api/user-file-other'])('does not extend raw-text upload exception to %s', async path => {
|
||||
const { ctx, next } = await check(path, 'POST', { 'content-type': 'text/plain' })
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test.each(['/api/tmp-file', '/api/user-file'])('does not accept form or multipart uploads at %s', async path => {
|
||||
for (const contentType of ['application/x-www-form-urlencoded', 'multipart/form-data; boundary=example']) {
|
||||
const { ctx, next } = await check(path, 'POST', { 'content-type': contentType })
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(['PUT', 'PATCH', 'DELETE', 'GET', 'HEAD', 'OPTIONS'])('does not extend upload exceptions to %s', async method => {
|
||||
for (const [path, contentType] of [
|
||||
['/api/tmp-file', 'text/plain'],
|
||||
['/api/user-file', 'text/plain'],
|
||||
['/api/attachment', 'multipart/form-data; boundary=example'],
|
||||
]) {
|
||||
const { ctx, next } = await check(path, method, { 'content-type': contentType, 'content-length': '5' })
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(['multipart/form-data; boundary=example', 'application/json'])('preserves attachment uploads with %s', async contentType => {
|
||||
const { next } = await check('/api/attachment', 'POST', { 'content-type': contentType })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test.each(['/api/attachment/extra', '/api/attachment-other'])('does not extend multipart exception to %s', async path => {
|
||||
const { ctx, next } = await check(path, 'POST', { 'content-type': 'multipart/form-data; boundary=example' })
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test.each([
|
||||
'/api/run',
|
||||
'/api/run/extra',
|
||||
'/api/runtime',
|
||||
'/api/convert/export.html',
|
||||
'/api/history/delete',
|
||||
'/api/history/comment',
|
||||
'/api/watch-file',
|
||||
])('requires POST and JSON for the body-driven handler %s', async path => {
|
||||
for (const method of ['GET', 'HEAD', 'DELETE', 'PUT', 'PATCH', 'OPTIONS']) {
|
||||
const { ctx, next } = await check(path, method, { 'content-type': 'application/json' })
|
||||
expect(ctx.status).toBe(405)
|
||||
expect(ctx.set).toHaveBeenCalledWith('Allow', 'POST')
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
expect((await check(path)).ctx.status).toBe(415)
|
||||
expect((await check(path, 'POST', { 'content-type': 'application/json' })).next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test.each([
|
||||
['/api/extensions/install', 'POST'],
|
||||
['/api/extensions/uninstall', 'POST'],
|
||||
['/api/extensions/enable', 'POST'],
|
||||
['/api/extensions/disable', 'POST'],
|
||||
['/api/extensions/abort-installation', 'DELETE'],
|
||||
])('preserves bodyless %s %s but rejects method and MIME bypasses', async (path, method) => {
|
||||
expect((await check(path, method)).next).toHaveBeenCalledOnce()
|
||||
expect((await check(path, method, { 'content-length': '0' })).next).toHaveBeenCalledOnce()
|
||||
expect((await check(path, method, { 'content-type': 'application/json' })).next).toHaveBeenCalledOnce()
|
||||
expect((await check(path, 'GET')).next).toHaveBeenCalledOnce()
|
||||
expect((await check(path + '/suffix', 'GET')).next).toHaveBeenCalledOnce()
|
||||
|
||||
for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', 'multipart/form-data; boundary=example']) {
|
||||
const { ctx, next } = await check(path, method, { 'content-type': contentType })
|
||||
expect(ctx.status).toBe(415)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
for (const otherMethod of ['HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'].filter(value => value !== method)) {
|
||||
const { ctx, next } = await check(path + '/suffix', otherMethod)
|
||||
expect(ctx.status).toBe(405)
|
||||
expect(ctx.set).toHaveBeenCalledWith('Allow', `GET, ${method}`)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
test('does not broaden the streaming proxy exception', async () => {
|
||||
const headers = { 'content-type': 'text/plain' }
|
||||
expect((await check('/api/proxy-fetch/https://example.test', 'POST', headers)).next).toHaveBeenCalledOnce()
|
||||
expect((await check('/api/proxy-fetch', 'POST', headers)).ctx.status).toBe(415)
|
||||
expect((await check('/api/proxy-fetch-other', 'POST', headers)).ctx.status).toBe(415)
|
||||
})
|
||||
|
||||
test.each(['/index.html', '/custom-css', '/extensions/sample.js', '/api-other'])('leaves non-API path %s alone', async path => {
|
||||
const { next } = await check(path, 'POST', { 'content-type': 'text/plain' })
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API body policy before the real Koa body parser', () => {
|
||||
let server: Server
|
||||
let origin: string
|
||||
let parserVisits = 0
|
||||
let handlerVisits = 0
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = new Koa()
|
||||
app.silent = true
|
||||
app.use(checkApiRequestBody)
|
||||
const parse = bodyParser({ multipart: true })
|
||||
app.use(async (ctx, next) => {
|
||||
parserVisits++
|
||||
await parse(ctx, next)
|
||||
})
|
||||
app.use(ctx => {
|
||||
handlerVisits++
|
||||
ctx.body = { parsed: ctx.request.body }
|
||||
})
|
||||
await new Promise<void>(resolve => {
|
||||
server = app.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(error => error ? reject(error) : resolve())
|
||||
server.closeAllConnections()
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
parserVisits = 0
|
||||
handlerVisits = 0
|
||||
})
|
||||
|
||||
test('parses normal API JSON', async () => {
|
||||
const response = await fetch(origin + '/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ language: 'en' }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ parsed: { language: 'en' } })
|
||||
expect(handlerVisits).toBe(1)
|
||||
})
|
||||
|
||||
test.each(['application/x-www-form-urlencoded', 'text/plain'])('rejects %s before the parser or handler runs', async contentType => {
|
||||
const response = await fetch(origin + '/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: 'language=en',
|
||||
})
|
||||
expect(response.status).toBe(415)
|
||||
expect(parserVisits).toBe(0)
|
||||
expect(handlerVisits).toBe(0)
|
||||
})
|
||||
|
||||
test('rejects multipart outside the exact attachment endpoint before parsing', async () => {
|
||||
const data = new FormData()
|
||||
data.set('language', 'en')
|
||||
const response = await fetch(origin + '/api/settings', { method: 'POST', body: data })
|
||||
expect(response.status).toBe(415)
|
||||
expect(parserVisits).toBe(0)
|
||||
expect(handlerVisits).toBe(0)
|
||||
})
|
||||
|
||||
test('preserves the actual multipart attachment fields', async () => {
|
||||
const fields = { repo: 'main', path: '/image.png', attachment: 'data:image/png;base64,aGk=', exists: 'rename' }
|
||||
const data = new FormData()
|
||||
Object.entries(fields).forEach(([key, value]) => data.set(key, value))
|
||||
const response = await fetch(origin + '/api/attachment', { method: 'POST', body: data })
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ parsed: fields })
|
||||
expect(handlerVisits).toBe(1)
|
||||
})
|
||||
|
||||
test.each(['/api/tmp-file', '/api/user-file'])('preserves the raw text body at %s', async path => {
|
||||
const body = '{"this":"must stay text"}'
|
||||
const response = await fetch(origin + path, { method: 'POST', body })
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ parsed: body })
|
||||
expect(handlerVisits).toBe(1)
|
||||
})
|
||||
|
||||
test('invalid JSON cannot reach an API handler', async () => {
|
||||
const response = await fetch(origin + '/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{',
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
expect(parserVisits).toBe(1)
|
||||
expect(handlerVisits).toBe(0)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['/api/run', 'GET'],
|
||||
['/api/convert/export.html', 'DELETE'],
|
||||
['/api/history/delete', 'HEAD'],
|
||||
['/api/history/comment', 'OPTIONS'],
|
||||
['/api/watch-file', 'GET'],
|
||||
['/api/extensions/install', 'HEAD'],
|
||||
['/api/extensions/enable', 'OPTIONS'],
|
||||
])('rejects unsupported %s %s before parsing or mutation', async (path, method) => {
|
||||
const response = await fetch(origin + path, { method })
|
||||
expect(response.status).toBe(405)
|
||||
expect(response.headers.get('allow')).toBe(path.startsWith('/api/extensions/') ? 'GET, POST' : 'POST')
|
||||
expect(parserVisits).toBe(0)
|
||||
expect(handlerVisits).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { networkInterfaces } from 'os'
|
||||
import { createRequestOriginGuard } from '../request-origin'
|
||||
|
||||
vi.mock('os', () => {
|
||||
const networkInterfaces = vi.fn(() => ({
|
||||
eth0: [{ address: '192.168.1.4', family: 'IPv4' }, { address: '2001:db8::1', family: 'IPv6' }]
|
||||
}))
|
||||
return { networkInterfaces, default: { networkInterfaces } }
|
||||
})
|
||||
|
||||
describe('request origin boundary', () => {
|
||||
const check = createRequestOriginGuard(3044, '127.0.0.1')
|
||||
|
||||
test.each([
|
||||
{ host: 'localhost:3044', origin: 'http://localhost:3044' },
|
||||
{ host: '127.0.0.1:3044', origin: 'http://127.0.0.1:3044' },
|
||||
{ host: '[::1]:3044', origin: 'http://[::1]:3044' },
|
||||
{ host: 'localhost:3044', referer: 'http://localhost:3044/embed/?a=1' },
|
||||
{ host: 'localhost:3044', 'sec-fetch-site': 'same-origin' },
|
||||
{ host: 'localhost:3044', origin: 'yank-note://localhost', 'sec-fetch-site': 'cross-site' }
|
||||
])('accepts a trusted browser source: %j', headers => {
|
||||
expect(check(headers)).toBe(true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{},
|
||||
{ host: 'localhost:3044' },
|
||||
{ host: 'localhost:3044', origin: 'null', 'sec-fetch-site': 'same-origin' },
|
||||
{ host: 'localhost:3044', origin: '' },
|
||||
{ host: 'localhost:3044', origin: 'file://' },
|
||||
{ host: 'localhost:3044', origin: 'http://localhost:3044/path' },
|
||||
{ host: 'localhost:3044', origin: 'http://user@localhost:3044' },
|
||||
{ host: 'localhost:3044', origin: 'http://localhost:3044 https://evil.example' },
|
||||
{ host: 'localhost:3044', origin: 'http://localhost:3045' },
|
||||
{ host: 'localhost:3044', origin: 'https://localhost:3044' },
|
||||
{ host: 'localhost:3044', origin: 'http://127.0.0.1:3044' },
|
||||
{ host: 'localhost:3044', referer: 'http://localhost:3044/', 'sec-fetch-site': 'same-site' },
|
||||
{ host: 'localhost:3044', origin: 'http://localhost:3044', referer: 'https://evil.example/' },
|
||||
{ host: 'localhost:3044', origin: 'http://localhost:3044', 'sec-fetch-site': 'cross-site' },
|
||||
{ host: 'evil.example:3044', origin: 'http://evil.example:3044', 'sec-fetch-site': 'same-origin' },
|
||||
{ host: 'evil.example:3044', origin: 'http://localhost:3044', 'x-forwarded-host': 'localhost:3044' },
|
||||
{ host: 'localhost:3044.evil.example', origin: 'http://localhost:3044' },
|
||||
{ host: 'localhost:3044', origin: 'yank-note://evil.example' },
|
||||
{ host: 'localhost:3044', origin: 'yank-note://localhost:1234' }
|
||||
])('rejects missing, opaque, mismatched or rebinding sources: %j', headers => {
|
||||
expect(check(headers)).toBe(false)
|
||||
})
|
||||
|
||||
test('allows actual interface addresses for a wildcard listener, never arbitrary DNS names', () => {
|
||||
const wildcard = createRequestOriginGuard(3044, '0.0.0.0')
|
||||
expect(wildcard({ host: '192.168.1.4:3044', origin: 'http://192.168.1.4:3044' })).toBe(true)
|
||||
expect(wildcard({ host: '[2001:db8::1]:3044', origin: 'http://[2001:db8::1]:3044' })).toBe(true)
|
||||
expect(wildcard({ host: 'rebound.example:3044', origin: 'http://rebound.example:3044' })).toBe(false)
|
||||
})
|
||||
|
||||
test('fails closed if interface enumeration is unavailable', () => {
|
||||
vi.mocked(networkInterfaces).mockImplementationOnce(() => { throw new Error('Unavailable') })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const wildcard = createRequestOriginGuard(3044, '::')
|
||||
expect(wildcard({ host: 'localhost:3044', origin: 'http://localhost:3044' })).toBe(true)
|
||||
expect(wildcard({ host: 'notes.example', origin: 'https://notes.example' })).toBe(false)
|
||||
expect(wildcard({ host: 'unknown.example', origin: 'http://unknown.example' })).toBe(false)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('supports the default HTTP port for the Electron terminal', () => {
|
||||
expect(createRequestOriginGuard(80, '127.0.0.1')({ host: 'localhost', origin: 'yank-note://localhost' }, false, true)).toBe(true)
|
||||
})
|
||||
|
||||
test('requires explicit Origin for WebSocket even with matching fetch metadata', () => {
|
||||
expect(check({ host: 'localhost:3044', 'sec-fetch-site': 'same-origin' }, false, true)).toBe(false)
|
||||
expect(check({ host: 'localhost:3044', origin: 'http://localhost:3044' }, false, true)).toBe(true)
|
||||
})
|
||||
|
||||
test('only grants the metadata-free exception to internal Electron transport', () => {
|
||||
expect(check({}, true)).toBe(true)
|
||||
expect(check({}, false)).toBe(false)
|
||||
expect(check({ origin: 'null' }, true)).toBe(false)
|
||||
expect(check({ origin: 'https://evil.example' }, true)).toBe(false)
|
||||
expect(check({ origin: 'yank-note://localhost' }, true)).toBe(true)
|
||||
expect(check({ 'sec-fetch-site': 'cross-site' }, true)).toBe(false)
|
||||
})
|
||||
|
||||
test('only API mode allows source-free native requests; explicit bad evidence never falls back', () => {
|
||||
expect(check({ host: 'localhost:3044' }, false, false, true)).toBe(true)
|
||||
expect(check({ host: 'localhost:3044', 'sec-fetch-mode': 'cors' }, false, false, true)).toBe(true)
|
||||
for (const headers of [
|
||||
{ origin: 'null' }, { origin: '' }, { origin: 'https://evil.example' },
|
||||
{ referer: '' }, { referer: 'https://evil.example/' },
|
||||
{ 'sec-fetch-site': 'same-site' }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': '' },
|
||||
{ host: 'evil.example:3044' }
|
||||
]) {
|
||||
expect(check({ host: 'localhost:3044', ...headers }, false, false, true)).toBe(false)
|
||||
}
|
||||
expect(check({ host: 'localhost:3044' }, false, true, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,9 @@ import ip from 'ip'
|
||||
import * as fs from 'fs-extra'
|
||||
import uniq from 'lodash/uniq'
|
||||
import type NodePty from 'node-pty'
|
||||
import type { IncomingMessage } from 'http'
|
||||
import { createRequestOriginGuard } from './request-origin'
|
||||
import { checkApiRequestBody } from './request-body'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import * as path from 'path'
|
||||
import Koa from 'koa'
|
||||
@@ -26,7 +29,7 @@ import * as mcpServer from './mcp'
|
||||
import type { FileReadResult } from '../../share/types'
|
||||
|
||||
const isLocalhost = (address: string) => {
|
||||
return ip.isEqual(address, '127.0.0.1') || ip.isEqual(address, '::1')
|
||||
return !!address && (ip.isEqual(address, '127.0.0.1') || ip.isEqual(address, '::1'))
|
||||
}
|
||||
|
||||
const result = (status: 'ok' | 'error' = 'ok', message = 'success', data: any = null) => {
|
||||
@@ -612,7 +615,9 @@ const setting = async (ctx: any, next: any) => {
|
||||
if (ctx.method === 'GET') {
|
||||
const getSettings = () => {
|
||||
if (isAdmin(ctx)) {
|
||||
return config.getAll()
|
||||
const data = { ...config.getAll() }
|
||||
delete data['server.jwt-secret']
|
||||
return data
|
||||
} else {
|
||||
const data = { ...config.getAll() }
|
||||
data.repositories = {}
|
||||
@@ -713,6 +718,8 @@ const rpc = async (ctx: any, next: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const requestSourceGuard = Symbol('requestSourceGuard')
|
||||
|
||||
const sendFile = async (ctx: any, next: any, filePath: string, fullback = true) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
if (fullback) {
|
||||
@@ -730,6 +737,38 @@ const sendFile = async (ctx: any, next: any, filePath: string, fullback = true)
|
||||
return true
|
||||
}
|
||||
|
||||
// Check the resolved executable document, covering /static and encoded path
|
||||
// aliases as well as directory index resolution. Native API compatibility
|
||||
// must never grant a source-free navigation into this document.write page.
|
||||
const embedPath = path.resolve(STATIC_DIR, 'embed/index.html')
|
||||
// Conservatively fold Windows case, trailing-dot/space and stream spellings,
|
||||
// including on filesystems that do not expose a useful inode identifier.
|
||||
const resourceName = (name: string) => path.resolve(name).toLowerCase().split(path.sep)
|
||||
.map(part => part.split(':', 1)[0].replace(/[. ]+$/, '')).join(path.sep)
|
||||
let executableEmbed = resourceName(filePath) === resourceName(embedPath)
|
||||
if (!executableEmbed && fs.existsSync(embedPath)) {
|
||||
const embedStat = fs.statSync(embedPath)
|
||||
if (fileStat.ino > 0 && embedStat.ino > 0) {
|
||||
executableEmbed = fileStat.dev === embedStat.dev && fileStat.ino === embedStat.ino
|
||||
}
|
||||
if (!executableEmbed) {
|
||||
// Electron ASAR can synthesize a new inode on every stat. Resolve aliases
|
||||
// even when positive inode values differ. Fail closed if this fails.
|
||||
try {
|
||||
executableEmbed = resourceName(fs.realpathSync(filePath)) === resourceName(fs.realpathSync(embedPath))
|
||||
} catch (_) {
|
||||
ctx.status = 403
|
||||
ctx.body = result('error', 'Unable to validate static resource')
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (executableEmbed && !ctx.req[requestSourceGuard](ctx.headers, ctx.req._protocol === true)) {
|
||||
ctx.status = 403
|
||||
ctx.body = result('error', 'Forbidden request origin')
|
||||
return true
|
||||
}
|
||||
|
||||
ctx.body = await promisify(fs.readFile)(filePath)
|
||||
ctx.set('Content-Length', fileStat.size)
|
||||
ctx.set('Last-Modified', fileStat.mtime.toUTCString())
|
||||
@@ -830,8 +869,25 @@ export async function killPtyProcesses (pty?: NodePty.IPty) {
|
||||
|
||||
const server = (port = 3000) => {
|
||||
const app = new Koa()
|
||||
const host = config.get('server.host', '127.0.0.1')
|
||||
const checkOrigin = createRequestOriginGuard(port, host)
|
||||
|
||||
app.use(async (ctx: any, next: any) => {
|
||||
const internal = ctx.req._protocol === true
|
||||
ctx.req[requestSourceGuard] = checkOrigin
|
||||
// Host validation also protects static pages against DNS rebinding. Native
|
||||
// API requests retain existing authentication only when provenance is absent.
|
||||
if ((!internal && !checkOrigin.allowsHost(ctx.headers)) ||
|
||||
(ctx.path.startsWith('/api') && !checkOrigin(ctx.headers, internal, false, true))) {
|
||||
ctx.status = 403
|
||||
ctx.body = result('error', 'Forbidden request origin')
|
||||
return
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
app.use(async (ctx: any, next: any) => await wrapper(ctx, next, checkPermission))
|
||||
app.use(checkApiRequestBody)
|
||||
app.use(async (ctx: any, next: any) => await wrapper(ctx, next, proxy))
|
||||
|
||||
app.use(bodyParser({
|
||||
@@ -879,8 +935,20 @@ const server = (port = 3000) => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const server = require('http').createServer(callback)
|
||||
const isAllowedSocketRequest = (req: IncomingMessage) =>
|
||||
checkOrigin(req.headers, false, req.headers.upgrade?.toLowerCase() === 'websocket') &&
|
||||
isLocalhost(req.socket.remoteAddress || '')
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const io = require('socket.io')(server, { path: '/ws' })
|
||||
const io = require('socket.io')(server, {
|
||||
path: '/ws',
|
||||
allowRequest: (req: IncomingMessage, callback: (error: string | null, allowed: boolean) => void) => {
|
||||
const allowed = isAllowedSocketRequest(req)
|
||||
callback(allowed ? null : 'Forbidden request origin', allowed)
|
||||
}
|
||||
})
|
||||
io.engine.use((req: IncomingMessage, _res: unknown, next: (error?: Error) => void) => {
|
||||
next(isAllowedSocketRequest(req) ? undefined : new Error('Forbidden request origin'))
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
let pty: typeof NodePty | null = null
|
||||
@@ -892,7 +960,7 @@ const server = (port = 3000) => {
|
||||
}
|
||||
|
||||
io.on('connection', (socket: any) => {
|
||||
if (!isLocalhost(socket.client.conn.remoteAddress)) {
|
||||
if (!isLocalhost(socket.client.conn.remoteAddress) || !checkOrigin(socket.handshake.headers || {}, false, socket.handshake.headers?.upgrade?.toLowerCase() === 'websocket')) {
|
||||
socket.disconnect()
|
||||
return
|
||||
}
|
||||
@@ -937,7 +1005,6 @@ const server = (port = 3000) => {
|
||||
}
|
||||
})
|
||||
|
||||
const host = config.get('server.host', '127.0.0.1')
|
||||
server.listen(port, host)
|
||||
|
||||
console.log(`Address: http://${host}:${port}`)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Middleware } from 'koa'
|
||||
|
||||
const postOnlyPaths = new Set([
|
||||
'/api/history/delete',
|
||||
'/api/history/comment',
|
||||
'/api/watch-file',
|
||||
])
|
||||
|
||||
const extensionPostPrefixes = [
|
||||
'/api/extensions/install',
|
||||
'/api/extensions/uninstall',
|
||||
'/api/extensions/enable',
|
||||
'/api/extensions/disable',
|
||||
]
|
||||
|
||||
/** Run before the proxy, body parser and API handlers. */
|
||||
export const checkApiRequestBody: Middleware = async (ctx, next) => {
|
||||
if (ctx.path !== '/api' && !ctx.path.startsWith('/api/')) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
// This endpoint forwards the original stream, rather than parsing an API body.
|
||||
// It is still subject to the source and permission checks before this middleware.
|
||||
if (ctx.path.startsWith('/api/proxy-fetch/')) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
const extensionPost = extensionPostPrefixes.some(prefix => ctx.path.startsWith(prefix))
|
||||
const extensionDelete = ctx.path.startsWith('/api/extensions/abort-installation')
|
||||
const extensionAction = extensionPost || extensionDelete
|
||||
const postOnly = postOnlyPaths.has(ctx.path) || ctx.path.startsWith('/api/run') || ctx.path.startsWith('/api/convert/')
|
||||
const requiredMethod = extensionDelete ? 'DELETE' : (extensionPost || postOnly) ? 'POST' : undefined
|
||||
|
||||
// Every GET /api/extensions* is a read-only list in the existing handler.
|
||||
if (requiredMethod && ctx.method !== requiredMethod && !(extensionAction && ctx.method === 'GET')) {
|
||||
ctx.status = 405
|
||||
ctx.set('Allow', extensionAction ? `GET, ${requiredMethod}` : requiredMethod)
|
||||
ctx.body = { status: 'error', message: 'Method not allowed', data: null }
|
||||
return
|
||||
}
|
||||
|
||||
const contentType = ctx.headers['content-type']
|
||||
const mediaType = typeof contentType === 'string' ? contentType.split(';', 1)[0].trim().toLowerCase() : ''
|
||||
const hasBody = Number(ctx.headers['content-length'] || 0) > 0 || ctx.headers['transfer-encoding'] !== undefined
|
||||
const bodyMethod = ['POST', 'PUT', 'PATCH'].includes(ctx.method)
|
||||
|
||||
// Extension actions and GET/DELETE calls already pass their arguments in the
|
||||
// URL. Preserve their bodyless requests without requiring a Content-Type.
|
||||
if (!hasBody && contentType === undefined && (!bodyMethod || extensionPost)) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
const textUpload = ctx.method === 'POST' && (ctx.path === '/api/tmp-file' || ctx.path === '/api/user-file')
|
||||
const multipartUpload = ctx.method === 'POST' && ctx.path === '/api/attachment'
|
||||
|
||||
if (mediaType !== 'application/json' &&
|
||||
!(textUpload && mediaType === 'text/plain') &&
|
||||
!(multipartUpload && mediaType === 'multipart/form-data')) {
|
||||
ctx.status = 415
|
||||
ctx.body = { status: 'error', message: 'Unsupported API request Content-Type', data: null }
|
||||
return
|
||||
}
|
||||
|
||||
await next()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { IncomingHttpHeaders } from 'http'
|
||||
import { networkInterfaces } from 'os'
|
||||
|
||||
const desktopOrigin = 'yank-note://localhost'
|
||||
|
||||
function parseOrigin (value: unknown, allowPath = false): string | undefined {
|
||||
if (typeof value !== 'string' || !value || value.trim() !== value || value === 'null') return
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.username || url.password || (!allowPath && ((url.pathname !== '/' && url.pathname !== '') || url.search || url.hash))) return
|
||||
if (url.protocol === 'yank-note:' && url.host === 'localhost') return desktopOrigin
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return
|
||||
return url.origin
|
||||
} catch (_) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function createRequestOriginGuard (port: number, host: string) {
|
||||
// Never derive the allowlist from Host or forwarding headers: they are attacker-controlled.
|
||||
const hosts = new Set(['localhost', '127.0.0.1', '[::1]'])
|
||||
if (host === '0.0.0.0' || host === '::') {
|
||||
try {
|
||||
Object.values(networkInterfaces()).flat().forEach(address => {
|
||||
if (address && !address.address.includes('%')) {
|
||||
hosts.add(address.family === 'IPv6' ? `[${address.address}]` : address.address)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Sandboxed platforms may deny interface enumeration. Fail closed to
|
||||
// loopback instead of trusting request Host.
|
||||
console.warn('Unable to enumerate server interfaces', error)
|
||||
}
|
||||
} else {
|
||||
hosts.add(host.includes(':') && !host.startsWith('[') ? `[${host}]` : host)
|
||||
}
|
||||
|
||||
const origins = new Set([...hosts].map(hostname => new URL(`http://${hostname}:${port}`).origin))
|
||||
const authorities = new Set([...origins].map(origin => new URL(origin).host))
|
||||
|
||||
const allowsHost = (headers: IncomingHttpHeaders) =>
|
||||
typeof headers.host === 'string' && authorities.has(headers.host)
|
||||
|
||||
const check = (headers: IncomingHttpHeaders, internalProtocol = false, websocket = false, allowNative = false): boolean => {
|
||||
const originHeader = headers.origin
|
||||
const refererHeader = headers.referer
|
||||
const origin = parseOrigin(originHeader)
|
||||
const referer = parseOrigin(refererHeader, true)
|
||||
|
||||
// Reject opaque, malformed and conflicting evidence rather than falling back past it.
|
||||
if (originHeader !== undefined && !origin) return false
|
||||
if (refererHeader !== undefined && !referer) return false
|
||||
|
||||
if (internalProtocol) {
|
||||
return (!origin || origin === desktopOrigin) && (!referer || referer === desktopOrigin) &&
|
||||
(!headers['sec-fetch-site'] || headers['sec-fetch-site'] === 'same-origin' || headers['sec-fetch-site'] === 'none')
|
||||
}
|
||||
|
||||
const authority = headers.host
|
||||
if (typeof authority !== 'string' || !authorities.has(authority)) return false
|
||||
|
||||
// Electron's privileged, standard scheme connects to the local terminal over HTTP.
|
||||
if (origin === desktopOrigin) {
|
||||
return ['localhost', '127.0.0.1', '[::1]'].includes(new URL(`http://${authority}`).hostname) &&
|
||||
(new URL(`http://${authority}`).port || '80') === String(port) && (!referer || referer === desktopOrigin)
|
||||
}
|
||||
|
||||
const matchesTarget = (source: string) => origins.has(source) && new URL(source).host === authority
|
||||
if (origin && !matchesTarget(origin)) return false
|
||||
if (referer && !matchesTarget(referer)) return false
|
||||
if (origin && referer && origin !== referer) return false
|
||||
if (headers['sec-fetch-site'] !== undefined && headers['sec-fetch-site'] !== 'same-origin') return false
|
||||
|
||||
// A WebSocket handshake must carry Origin. GET/script/image requests often do not;
|
||||
// Fetch Metadata or an exact Referer preserves those browser resource loads.
|
||||
if (websocket) return !!origin
|
||||
// Preserve existing native HTTP API authentication when no source evidence
|
||||
// exists. This exception is never used for executable embeds or terminals.
|
||||
return !!origin || !!referer || headers['sec-fetch-site'] === 'same-origin' || allowNative
|
||||
}
|
||||
|
||||
return Object.assign(check, { allowsHost })
|
||||
}
|
||||
@@ -162,7 +162,12 @@ export default defineComponent({
|
||||
? location.origin
|
||||
: 'http://' + location.hostname + ':' + $args().get('port')
|
||||
|
||||
socket = io(uri, { path: '/ws', query })
|
||||
socket = io(uri, {
|
||||
path: '/ws',
|
||||
query,
|
||||
// Custom-scheme XHR polling has an opaque Origin; WebSocket preserves it.
|
||||
...(location.protocol === 'yank-note:' ? { transports: ['websocket'] } : {}),
|
||||
})
|
||||
|
||||
xterm.onResize(size => socket!.emit('resize', [size.cols, size.rows]))
|
||||
xterm.onData(data => socket!.emit('input', data))
|
||||
|
||||
@@ -172,6 +172,26 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('Xterm', () => {
|
||||
test.each([
|
||||
['yank-note:', 'yank-note://localhost', 'http://localhost:9876', ['websocket']],
|
||||
['http:', 'http://localhost:8066', 'http://localhost:8066', undefined],
|
||||
['https:', 'https://localhost:8066', 'https://localhost:8066', undefined],
|
||||
['yank-note-other:', 'null', 'http://localhost:9876', undefined],
|
||||
])('selects the transport for %s without changing browser defaults', (protocol, origin, uri, transports) => {
|
||||
vi.stubGlobal('location', { protocol, origin, hostname: 'localhost' })
|
||||
const wrapper = mount(Xterm)
|
||||
|
||||
try {
|
||||
;(wrapper.vm as any).init({ cwd: '/repo', env: { A: '1' } })
|
||||
|
||||
const options = { path: '/ws', query: { cwd: '/repo', env: '{"A":"1"}' } }
|
||||
expect(mocks.io).toHaveBeenCalledWith(uri, transports ? { ...options, transports } : options)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
test('initializes terminal, socket, addons, resize handling and disposal', async () => {
|
||||
const onDisconnect = vi.fn()
|
||||
mocks.settings.set('terminal.font-size', 18)
|
||||
|
||||
Reference in New Issue
Block a user