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:
洋子
2026-10-07 11:36:57 +08:00
committed by GitHub
10 changed files with 964 additions and 23 deletions
+35
View File
@@ -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 版本、操作系统,以及相关配置或扩展。
- 最小且安全的复现步骤,必要时附上验证代码或已脱敏的日志。
- 可行的缓解或修复建议,以及希望使用的联系方式和署名方式。
请仅在自己拥有或获准使用的系统和数据上测试,避免破坏性操作、影响服务或访问他人数据。请使用模拟数据,并移除报告中的密码、令牌、个人信息及其他敏感内容。
## 协调披露
请给维护者合理的时间调查问题并准备修复或缓解措施。如有计划公开披露的日期,请提前沟通并共同讨论时间安排。在问题尚未解决时,请尽量避免公开可直接用于攻击的细节或验证代码。公开致谢及署名应与报告者协商一致。
+40
View File
@@ -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.
+244 -17
View File
@@ -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({
+296
View File
@@ -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)
})
})
+100
View File
@@ -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)
})
})
+72 -5
View File
@@ -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}`)
+68
View File
@@ -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()
}
+83
View File
@@ -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 })
}
+6 -1
View File
@@ -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)