@bibobonking/opensol 3.0.1 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -12
- package/package.json +1 -1
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs'
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const MIME = {
|
|
6
|
+
'.html': 'text/html; charset=utf-8',
|
|
7
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
8
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
9
|
+
'.css': 'text/css; charset=utf-8',
|
|
10
|
+
'.json': 'application/json; charset=utf-8',
|
|
11
|
+
'.svg': 'image/svg+xml',
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.jpg': 'image/jpeg',
|
|
14
|
+
'.jpeg': 'image/jpeg',
|
|
15
|
+
'.webp': 'image/webp',
|
|
16
|
+
'.gif': 'image/gif',
|
|
17
|
+
'.ico': 'image/x-icon',
|
|
18
|
+
'.woff': 'font/woff',
|
|
19
|
+
'.woff2': 'font/woff2',
|
|
20
|
+
'.ttf': 'font/ttf',
|
|
21
|
+
'.map': 'application/json; charset=utf-8',
|
|
22
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
23
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
24
|
+
'.wasm': 'application/wasm',
|
|
25
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
26
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
27
|
+
'.avif': 'image/avif',
|
|
28
|
+
'.otf': 'font/otf',
|
|
29
|
+
'.mp3': 'audio/mpeg',
|
|
30
|
+
'.wav': 'audio/wav',
|
|
31
|
+
'.ogg': 'audio/ogg',
|
|
32
|
+
'.mp4': 'video/mp4',
|
|
33
|
+
'.webm': 'video/webm',
|
|
34
|
+
'.glb': 'model/gltf-binary',
|
|
35
|
+
'.gltf': 'model/gltf+json',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mimeFor(file) {
|
|
39
|
+
return MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function streamFile(req, res, absolute, size, headers = {}) {
|
|
43
|
+
res.writeHead(200, {
|
|
44
|
+
'Content-Type': mimeFor(absolute),
|
|
45
|
+
'Content-Length': size,
|
|
46
|
+
...headers,
|
|
47
|
+
})
|
|
48
|
+
if (req.method === 'HEAD') {
|
|
49
|
+
res.end()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const stream = createReadStream(absolute)
|
|
53
|
+
stream.on('error', () => res.destroy())
|
|
54
|
+
stream.pipe(res)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class HttpError extends Error {
|
|
58
|
+
constructor(status, message, code = '') {
|
|
59
|
+
super(message)
|
|
60
|
+
this.name = 'HttpError'
|
|
61
|
+
this.status = status
|
|
62
|
+
this.code = code
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function securityHeaders(res) {
|
|
67
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
68
|
+
res.setHeader('X-Frame-Options', 'DENY')
|
|
69
|
+
res.setHeader('Referrer-Policy', 'no-referrer')
|
|
70
|
+
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
|
71
|
+
res.setHeader(
|
|
72
|
+
'Content-Security-Policy',
|
|
73
|
+
[
|
|
74
|
+
"default-src 'self'",
|
|
75
|
+
// `blob:` lets a user-approved Safe plugin load as a module without unsafe-eval.
|
|
76
|
+
"script-src 'self' 'unsafe-inline' blob:",
|
|
77
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
78
|
+
"font-src 'self' https://fonts.gstatic.com",
|
|
79
|
+
"img-src 'self' data: blob:",
|
|
80
|
+
"connect-src 'self'",
|
|
81
|
+
"frame-ancestors 'none'",
|
|
82
|
+
"base-uri 'self'",
|
|
83
|
+
"form-action 'self'",
|
|
84
|
+
].join('; '),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function json(res, status, value, headers = {}) {
|
|
89
|
+
const body = Buffer.from(JSON.stringify(value))
|
|
90
|
+
res.writeHead(status, {
|
|
91
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
92
|
+
'Content-Length': body.length,
|
|
93
|
+
'Cache-Control': 'no-store',
|
|
94
|
+
...headers,
|
|
95
|
+
})
|
|
96
|
+
res.end(body)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function sendError(res, error) {
|
|
100
|
+
if (res.headersSent) {
|
|
101
|
+
res.destroy()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const status = error instanceof HttpError ? error.status : 500
|
|
105
|
+
const message =
|
|
106
|
+
error instanceof HttpError
|
|
107
|
+
? error.message
|
|
108
|
+
: error instanceof Error
|
|
109
|
+
? error.message
|
|
110
|
+
: 'The daemon hit an unexpected error.'
|
|
111
|
+
json(res, status, {
|
|
112
|
+
error: {
|
|
113
|
+
message,
|
|
114
|
+
code: error instanceof HttpError ? error.code : 'daemon_error',
|
|
115
|
+
status,
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function readBody(req, maxBytes = 8 * 1024 * 1024) {
|
|
121
|
+
const declared = Number(req.headers['content-length'] ?? 0)
|
|
122
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
123
|
+
throw new HttpError(413, 'That request is too large.', 'request_too_large')
|
|
124
|
+
}
|
|
125
|
+
const chunks = []
|
|
126
|
+
let total = 0
|
|
127
|
+
for await (const chunk of req) {
|
|
128
|
+
total += chunk.length
|
|
129
|
+
if (total > maxBytes) {
|
|
130
|
+
throw new HttpError(413, 'That request is too large.', 'request_too_large')
|
|
131
|
+
}
|
|
132
|
+
chunks.push(chunk)
|
|
133
|
+
}
|
|
134
|
+
return Buffer.concat(chunks)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function readJson(req, maxBytes = 8 * 1024 * 1024) {
|
|
138
|
+
const body = await readBody(req, maxBytes)
|
|
139
|
+
if (body.length === 0) return {}
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(body.toString('utf8'))
|
|
142
|
+
} catch {
|
|
143
|
+
throw new HttpError(400, 'The request body is not valid JSON.', 'invalid_json')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function clientIp(req) {
|
|
148
|
+
return req.socket.remoteAddress ?? 'local'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function cookieValue(req, name) {
|
|
152
|
+
const header = req.headers.cookie
|
|
153
|
+
if (!header) return ''
|
|
154
|
+
for (const part of header.split(';')) {
|
|
155
|
+
const index = part.indexOf('=')
|
|
156
|
+
if (index === -1) continue
|
|
157
|
+
if (part.slice(0, index).trim() === name) return decodeURIComponent(part.slice(index + 1).trim())
|
|
158
|
+
}
|
|
159
|
+
return ''
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function setCookie(res, name, value, options = {}) {
|
|
163
|
+
const parts = [`${name}=${encodeURIComponent(value)}`, `Path=${options.path ?? '/'}`]
|
|
164
|
+
if (options.httpOnly !== false) parts.push('HttpOnly')
|
|
165
|
+
parts.push(`SameSite=${options.sameSite ?? 'Strict'}`)
|
|
166
|
+
if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`)
|
|
167
|
+
if (options.secure) parts.push('Secure')
|
|
168
|
+
const existing = res.getHeader('Set-Cookie')
|
|
169
|
+
const next = Array.isArray(existing) ? [...existing, parts.join('; ')] : existing ? [String(existing), parts.join('; ')] : [parts.join('; ')]
|
|
170
|
+
res.setHeader('Set-Cookie', next)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function serveStatic(req, res, root, pathname) {
|
|
174
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
175
|
+
throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
|
|
176
|
+
}
|
|
177
|
+
let relative
|
|
178
|
+
try {
|
|
179
|
+
relative = decodeURIComponent(pathname)
|
|
180
|
+
} catch {
|
|
181
|
+
throw new HttpError(400, 'That path is not valid.', 'invalid_path')
|
|
182
|
+
}
|
|
183
|
+
if (relative.includes('\0')) throw new HttpError(400, 'That path is not valid.', 'invalid_path')
|
|
184
|
+
relative = relative.replace(/^\/+/, '')
|
|
185
|
+
const candidate = path.resolve(root, relative)
|
|
186
|
+
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
|
|
187
|
+
if (candidate !== root && !candidate.startsWith(prefix)) {
|
|
188
|
+
throw new HttpError(403, 'That path is outside the app.', 'forbidden')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let target = candidate
|
|
192
|
+
let info
|
|
193
|
+
try {
|
|
194
|
+
info = await stat(target)
|
|
195
|
+
} catch {
|
|
196
|
+
info = null
|
|
197
|
+
}
|
|
198
|
+
if (info?.isDirectory()) {
|
|
199
|
+
target = path.join(target, 'index.html')
|
|
200
|
+
info = await stat(target).catch(() => null)
|
|
201
|
+
}
|
|
202
|
+
if (!info?.isFile()) {
|
|
203
|
+
// App routes are client-side. Only fall back for navigation requests.
|
|
204
|
+
const accepts = String(req.headers.accept ?? '')
|
|
205
|
+
if (pathname.startsWith('/api/') || (!accepts.includes('text/html') && pathname !== '/')) {
|
|
206
|
+
throw new HttpError(404, 'Not found.', 'not_found')
|
|
207
|
+
}
|
|
208
|
+
target = path.join(root, 'index.html')
|
|
209
|
+
info = await stat(target).catch(() => null)
|
|
210
|
+
}
|
|
211
|
+
if (!info?.isFile()) {
|
|
212
|
+
throw new HttpError(503, 'OpenSol is not built yet. Run the remote build and place the output in dist/.', 'dist_missing')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ext = path.extname(target).toLowerCase()
|
|
216
|
+
const immutable = target.includes(`${path.sep}assets${path.sep}`)
|
|
217
|
+
res.writeHead(200, {
|
|
218
|
+
'Content-Type': MIME[ext] ?? 'application/octet-stream',
|
|
219
|
+
'Content-Length': info.size,
|
|
220
|
+
'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache',
|
|
221
|
+
})
|
|
222
|
+
if (req.method === 'HEAD') {
|
|
223
|
+
res.end()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
const stream = createReadStream(target)
|
|
227
|
+
stream.on('error', () => res.destroy())
|
|
228
|
+
stream.pipe(res)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function parseJson(value, fallback = null) {
|
|
232
|
+
try {
|
|
233
|
+
return JSON.parse(value)
|
|
234
|
+
} catch {
|
|
235
|
+
return fallback
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import { lookup } from 'node:dns/promises'
|
|
3
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import https from 'node:https'
|
|
5
|
+
import { isIP } from 'node:net'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { HttpError } from './http.mjs'
|
|
8
|
+
import { DATA_DIR } from './store.mjs'
|
|
9
|
+
import { readPluginArchive } from '../../shared/plugin-package.mjs'
|
|
10
|
+
|
|
11
|
+
const PLUGINS_DIR = path.join(DATA_DIR, 'plugins')
|
|
12
|
+
const INDEX_FILE = path.join(PLUGINS_DIR, 'index.json')
|
|
13
|
+
const MAX_NETWORK_BODY = 64 * 1024
|
|
14
|
+
const MAX_NETWORK_RESPONSE = 1024 * 1024
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 12_000
|
|
16
|
+
let writes = Promise.resolve()
|
|
17
|
+
|
|
18
|
+
async function ensurePluginsDir() {
|
|
19
|
+
await mkdir(PLUGINS_DIR, { recursive: true, mode: 0o700 })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function readIndex() {
|
|
23
|
+
await ensurePluginsDir()
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(await readFile(INDEX_FILE, 'utf8'))
|
|
26
|
+
return parsed && typeof parsed === 'object' && Array.isArray(parsed.plugins)
|
|
27
|
+
? parsed.plugins
|
|
28
|
+
: []
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code === 'ENOENT') return []
|
|
31
|
+
if (error instanceof SyntaxError) throw new Error('OpenSol plugin index is damaged.')
|
|
32
|
+
throw error
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function saveIndex(plugins) {
|
|
37
|
+
const task = writes.then(async () => {
|
|
38
|
+
await ensurePluginsDir()
|
|
39
|
+
const temporary = `${INDEX_FILE}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
|
|
40
|
+
await writeFile(temporary, `${JSON.stringify({ plugins }, null, 2)}\n`, { mode: 0o600 })
|
|
41
|
+
await rename(temporary, INDEX_FILE)
|
|
42
|
+
})
|
|
43
|
+
writes = task.catch(() => undefined)
|
|
44
|
+
return task
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function publicRecord(plugin) {
|
|
48
|
+
const { id, manifest, manifestHash, installedAt, safe, enabled, grants } = plugin
|
|
49
|
+
return { id, manifest, manifestHash, installedAt, safe, enabled, grants }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function locate(plugins, id) {
|
|
53
|
+
const plugin = plugins.find((item) => item.id === id)
|
|
54
|
+
if (!plugin) throw new HttpError(404, 'Plugin not found.', 'plugin_not_found')
|
|
55
|
+
return plugin
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function listPlugins() {
|
|
59
|
+
return (await readIndex()).map(publicRecord)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function getPluginArchive(id) {
|
|
63
|
+
const plugins = await readIndex()
|
|
64
|
+
locate(plugins, id)
|
|
65
|
+
try {
|
|
66
|
+
return await readFile(path.join(PLUGINS_DIR, `${id}.plugin.json`))
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code === 'ENOENT') throw new HttpError(404, 'Plugin package is missing.', 'plugin_package_missing')
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function installPlugin(bytes) {
|
|
74
|
+
let parsed
|
|
75
|
+
try {
|
|
76
|
+
parsed = readPluginArchive(bytes)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new HttpError(400, error instanceof Error ? error.message : 'Invalid plugin package.', 'invalid_plugin')
|
|
79
|
+
}
|
|
80
|
+
const plugins = await readIndex()
|
|
81
|
+
const manifestHash = createHash('sha256').update(JSON.stringify(parsed.manifest)).digest('hex')
|
|
82
|
+
const previous = plugins.find((item) => item.id === parsed.manifest.id)
|
|
83
|
+
const unchanged = previous?.manifestHash === manifestHash
|
|
84
|
+
const record = {
|
|
85
|
+
id: parsed.manifest.id,
|
|
86
|
+
manifest: parsed.manifest,
|
|
87
|
+
manifestHash,
|
|
88
|
+
installedAt: previous?.installedAt ?? new Date().toISOString(),
|
|
89
|
+
safe: unchanged ? previous.safe : false,
|
|
90
|
+
enabled: unchanged ? previous.enabled : false,
|
|
91
|
+
grants: unchanged
|
|
92
|
+
? previous.grants
|
|
93
|
+
: { capabilities: [], domains: [] },
|
|
94
|
+
}
|
|
95
|
+
const archivePath = path.join(PLUGINS_DIR, `${record.id}.plugin.json`)
|
|
96
|
+
await writeFile(archivePath, bytes, { mode: 0o600 })
|
|
97
|
+
const next = previous
|
|
98
|
+
? plugins.map((item) => (item.id === record.id ? record : item))
|
|
99
|
+
: [...plugins, record]
|
|
100
|
+
await saveIndex(next)
|
|
101
|
+
return publicRecord(record)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function setPluginGrants(id, grants) {
|
|
105
|
+
const plugins = await readIndex()
|
|
106
|
+
const plugin = locate(plugins, id)
|
|
107
|
+
if (
|
|
108
|
+
!grants ||
|
|
109
|
+
typeof grants !== 'object' ||
|
|
110
|
+
!Array.isArray(grants.capabilities) ||
|
|
111
|
+
!Array.isArray(grants.domains)
|
|
112
|
+
) {
|
|
113
|
+
throw new HttpError(400, 'Plugin grants must list capabilities and domains.', 'invalid_plugin_grant')
|
|
114
|
+
}
|
|
115
|
+
const { capabilities, domains } = grants
|
|
116
|
+
if (
|
|
117
|
+
capabilities.some((value) => !plugin.manifest.capabilities.includes(value)) ||
|
|
118
|
+
domains.some((value) => !plugin.manifest.domains.includes(value))
|
|
119
|
+
) {
|
|
120
|
+
throw new HttpError(400, 'A plugin cannot be granted undeclared permissions.', 'invalid_plugin_grant')
|
|
121
|
+
}
|
|
122
|
+
const updated = {
|
|
123
|
+
...plugin,
|
|
124
|
+
grants: {
|
|
125
|
+
capabilities: [...new Set(capabilities)],
|
|
126
|
+
domains: [...new Set(domains)],
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
await saveIndex(plugins.map((item) => (item.id === id ? updated : item)))
|
|
130
|
+
return publicRecord(updated)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function setPluginSafe(id, safe) {
|
|
134
|
+
if (typeof safe !== 'boolean') throw new HttpError(400, 'Plugin trust must be explicitly true or false.', 'invalid_plugin_trust')
|
|
135
|
+
const plugins = await readIndex()
|
|
136
|
+
const plugin = locate(plugins, id)
|
|
137
|
+
const updated = { ...plugin, safe, enabled: safe ? plugin.enabled : false }
|
|
138
|
+
await saveIndex(plugins.map((item) => (item.id === id ? updated : item)))
|
|
139
|
+
return publicRecord(updated)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function setPluginEnabled(id, enabled) {
|
|
143
|
+
if (typeof enabled !== 'boolean') throw new HttpError(400, 'Plugin activation must be explicitly true or false.', 'invalid_plugin_activation')
|
|
144
|
+
const plugins = await readIndex()
|
|
145
|
+
const plugin = locate(plugins, id)
|
|
146
|
+
if (enabled && !plugin.safe) {
|
|
147
|
+
throw new HttpError(409, 'Review and trust the plugin before enabling it.', 'plugin_not_safe')
|
|
148
|
+
}
|
|
149
|
+
const updated = { ...plugin, enabled }
|
|
150
|
+
await saveIndex(plugins.map((item) => (item.id === id ? updated : item)))
|
|
151
|
+
return publicRecord(updated)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function uninstallPlugin(id) {
|
|
155
|
+
const plugins = await readIndex()
|
|
156
|
+
locate(plugins, id)
|
|
157
|
+
await rm(path.join(PLUGINS_DIR, `${id}.plugin.json`), { force: true })
|
|
158
|
+
await saveIndex(plugins.filter((item) => item.id !== id))
|
|
159
|
+
return { id, removed: true }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function privateAddress(address) {
|
|
163
|
+
if (isIP(address) === 4) {
|
|
164
|
+
const parts = address.split('.').map(Number)
|
|
165
|
+
return (
|
|
166
|
+
parts[0] === 0 ||
|
|
167
|
+
parts[0] === 10 ||
|
|
168
|
+
parts[0] === 127 ||
|
|
169
|
+
(parts[0] === 169 && parts[1] === 254) ||
|
|
170
|
+
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
|
171
|
+
(parts[0] === 192 && parts[1] === 168) ||
|
|
172
|
+
parts[0] >= 224
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
if (isIP(address) === 6) {
|
|
176
|
+
const lower = address.toLowerCase()
|
|
177
|
+
return (
|
|
178
|
+
lower === '::' ||
|
|
179
|
+
lower === '::1' ||
|
|
180
|
+
lower.startsWith('fc') ||
|
|
181
|
+
lower.startsWith('fd') ||
|
|
182
|
+
lower.startsWith('fe80') ||
|
|
183
|
+
(lower.startsWith('::ffff:') && privateAddress(lower.slice(7)))
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function addressesFor(hostname, lookupImpl) {
|
|
190
|
+
const literal = isIP(hostname)
|
|
191
|
+
const addresses = literal ? [{ address: hostname, family: literal }] : await lookupImpl(hostname, { all: true })
|
|
192
|
+
if (!addresses.length || addresses.some((entry) => privateAddress(entry.address))) {
|
|
193
|
+
throw new HttpError(403, 'Plugin network access to private hosts is blocked.', 'plugin_private_host')
|
|
194
|
+
}
|
|
195
|
+
return addresses
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function httpsRequest(url, method, headers, body, addresses) {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
const request = https.request(
|
|
201
|
+
{
|
|
202
|
+
protocol: 'https:',
|
|
203
|
+
hostname: url.hostname,
|
|
204
|
+
port: url.port || 443,
|
|
205
|
+
method,
|
|
206
|
+
path: `${url.pathname}${url.search}`,
|
|
207
|
+
headers,
|
|
208
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
209
|
+
lookup: (_hostname, options, callback) => {
|
|
210
|
+
if (options?.all) callback(null, addresses)
|
|
211
|
+
else callback(null, addresses[0].address, addresses[0].family)
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
(response) => {
|
|
215
|
+
const chunks = []
|
|
216
|
+
let total = 0
|
|
217
|
+
response.on('data', (chunk) => {
|
|
218
|
+
total += chunk.length
|
|
219
|
+
if (total > MAX_NETWORK_RESPONSE) {
|
|
220
|
+
request.destroy(new HttpError(413, 'Plugin network response is too large.', 'plugin_response_too_large'))
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
chunks.push(chunk)
|
|
224
|
+
})
|
|
225
|
+
response.on('end', () => resolve({
|
|
226
|
+
status: response.statusCode ?? 0,
|
|
227
|
+
headers: response.headers,
|
|
228
|
+
body: Buffer.concat(chunks),
|
|
229
|
+
}))
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
request.on('timeout', () => request.destroy(new HttpError(504, 'Plugin network request timed out.', 'plugin_request_timeout')))
|
|
233
|
+
request.on('error', reject)
|
|
234
|
+
if (body) request.write(body)
|
|
235
|
+
request.end()
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function requestPluginNetwork(id, request, {
|
|
240
|
+
fetchImpl,
|
|
241
|
+
lookupImpl = lookup,
|
|
242
|
+
} = {}) {
|
|
243
|
+
const plugins = await readIndex()
|
|
244
|
+
const plugin = locate(plugins, id)
|
|
245
|
+
if (!plugin.safe || !plugin.enabled) throw new HttpError(403, 'Plugin network access requires an enabled Safe plugin.', 'plugin_disabled')
|
|
246
|
+
if (!plugin.grants.capabilities.includes('network.request')) {
|
|
247
|
+
throw new HttpError(403, 'Plugin has not been granted network access.', 'plugin_capability_denied')
|
|
248
|
+
}
|
|
249
|
+
let url
|
|
250
|
+
try {
|
|
251
|
+
url = new URL(String(request?.url ?? ''))
|
|
252
|
+
} catch {
|
|
253
|
+
throw new HttpError(400, 'Plugin URL is invalid.', 'plugin_bad_url')
|
|
254
|
+
}
|
|
255
|
+
if (
|
|
256
|
+
url.protocol !== 'https:' ||
|
|
257
|
+
url.username ||
|
|
258
|
+
url.password ||
|
|
259
|
+
url.port && url.port !== '443' ||
|
|
260
|
+
!plugin.manifest.domains.includes(url.hostname.toLowerCase()) ||
|
|
261
|
+
!plugin.grants.domains.includes(url.hostname.toLowerCase())
|
|
262
|
+
) {
|
|
263
|
+
throw new HttpError(403, 'Plugin URL must use an explicitly granted HTTPS domain.', 'plugin_domain_denied')
|
|
264
|
+
}
|
|
265
|
+
const method = String(request.method ?? 'GET').toUpperCase()
|
|
266
|
+
if (!['GET', 'POST'].includes(method)) throw new HttpError(400, 'Plugin network method is not allowed.', 'plugin_bad_method')
|
|
267
|
+
const body = request.body === undefined ? undefined : String(request.body)
|
|
268
|
+
if (body && Buffer.byteLength(body) > MAX_NETWORK_BODY) {
|
|
269
|
+
throw new HttpError(413, 'Plugin network request body is too large.', 'plugin_request_too_large')
|
|
270
|
+
}
|
|
271
|
+
const headers = {}
|
|
272
|
+
for (const [key, value] of Object.entries(request.headers ?? {})) {
|
|
273
|
+
const lower = key.toLowerCase()
|
|
274
|
+
if (!['accept', 'content-type'].includes(lower) || typeof value !== 'string' || value.length > 200) {
|
|
275
|
+
throw new HttpError(400, 'Plugin request contains an unsupported header.', 'plugin_bad_header')
|
|
276
|
+
}
|
|
277
|
+
headers[lower] = value
|
|
278
|
+
}
|
|
279
|
+
const addresses = await addressesFor(url.hostname, lookupImpl)
|
|
280
|
+
const response = fetchImpl
|
|
281
|
+
? await fetchImpl(url, {
|
|
282
|
+
method,
|
|
283
|
+
headers,
|
|
284
|
+
body,
|
|
285
|
+
redirect: 'manual',
|
|
286
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
287
|
+
})
|
|
288
|
+
: await httpsRequest(url, method, headers, body, addresses)
|
|
289
|
+
if (response.status >= 300 && response.status < 400) {
|
|
290
|
+
throw new HttpError(403, 'Plugin network redirects are blocked.', 'plugin_redirect_blocked')
|
|
291
|
+
}
|
|
292
|
+
if (response.status < 200 || response.status >= 300) {
|
|
293
|
+
throw new HttpError(502, `Plugin network host returned HTTP ${response.status}.`, 'plugin_request_failed')
|
|
294
|
+
}
|
|
295
|
+
const getHeader = (name) =>
|
|
296
|
+
typeof response.headers?.get === 'function'
|
|
297
|
+
? response.headers.get(name)
|
|
298
|
+
: response.headers?.[name] ?? response.headers?.[name.toLowerCase()]
|
|
299
|
+
const contentType = getHeader('content-type')?.split(';')[0].trim().toLowerCase() ?? ''
|
|
300
|
+
if (!['application/json', 'text/plain', 'application/xml', 'text/xml'].includes(contentType)) {
|
|
301
|
+
throw new HttpError(415, 'Plugin response content type is not allowed.', 'plugin_content_type')
|
|
302
|
+
}
|
|
303
|
+
const disposition = getHeader('content-disposition') ?? ''
|
|
304
|
+
if (/attachment/i.test(disposition)) throw new HttpError(415, 'Plugin file downloads are blocked.', 'plugin_download_blocked')
|
|
305
|
+
const declaredLength = Number(getHeader('content-length') ?? 0)
|
|
306
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_NETWORK_RESPONSE) {
|
|
307
|
+
throw new HttpError(413, 'Plugin network response is too large.', 'plugin_response_too_large')
|
|
308
|
+
}
|
|
309
|
+
const bytes =
|
|
310
|
+
response.body instanceof Uint8Array
|
|
311
|
+
? Buffer.from(response.body)
|
|
312
|
+
: Buffer.from(await response.arrayBuffer())
|
|
313
|
+
if (bytes.length > MAX_NETWORK_RESPONSE) {
|
|
314
|
+
throw new HttpError(413, 'Plugin network response is too large.', 'plugin_response_too_large')
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
status: response.status,
|
|
318
|
+
contentType,
|
|
319
|
+
body: bytes.toString('utf8'),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import test, { beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { buildPluginArchive } from '../../shared/plugin-package.mjs'
|
|
7
|
+
|
|
8
|
+
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'maxcode-plugin-test-'))
|
|
9
|
+
process.env.MAXCODE_DATA = dataDir
|
|
10
|
+
const plugins = await import('./plugins.mjs')
|
|
11
|
+
|
|
12
|
+
const baseManifest = {
|
|
13
|
+
id: 'test-plugin',
|
|
14
|
+
name: 'Test Plugin',
|
|
15
|
+
version: '1.0.0',
|
|
16
|
+
apiVersion: 1,
|
|
17
|
+
entry: 'plugin.js',
|
|
18
|
+
stylesheet: 'plugin.css',
|
|
19
|
+
capabilities: ['network.request'],
|
|
20
|
+
domains: ['api.example.test'],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function packageBytes(manifest = baseManifest) {
|
|
24
|
+
return buildPluginArchive({
|
|
25
|
+
manifest,
|
|
26
|
+
files: { 'plugin.js': 'api.registerCommand("sample", () => {})', 'plugin.css': '' },
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function installAndGrant(manifest = baseManifest) {
|
|
31
|
+
await plugins.installPlugin(packageBytes(manifest))
|
|
32
|
+
await plugins.setPluginGrants(manifest.id, {
|
|
33
|
+
capabilities: manifest.capabilities,
|
|
34
|
+
domains: manifest.domains,
|
|
35
|
+
})
|
|
36
|
+
await plugins.setPluginSafe(manifest.id, true)
|
|
37
|
+
await plugins.setPluginEnabled(manifest.id, true)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
for (const plugin of await plugins.listPlugins()) await plugins.uninstallPlugin(plugin.id)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('installing a package starts disabled and not trusted', async () => {
|
|
45
|
+
const archive = packageBytes()
|
|
46
|
+
const installed = await plugins.installPlugin(archive)
|
|
47
|
+
assert.equal(installed.enabled, false)
|
|
48
|
+
assert.equal(installed.safe, false)
|
|
49
|
+
assert.deepEqual(installed.grants, { capabilities: [], domains: [] })
|
|
50
|
+
assert.equal((await plugins.listPlugins()).length, 1)
|
|
51
|
+
assert.deepEqual(await plugins.getPluginArchive(installed.id), Buffer.from(archive))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('enabling an untrusted plugin or granting undeclared access is rejected', async () => {
|
|
55
|
+
await plugins.installPlugin(packageBytes())
|
|
56
|
+
await assert.rejects(plugins.setPluginEnabled('test-plugin', true), /trust|Safe/i)
|
|
57
|
+
await assert.rejects(
|
|
58
|
+
plugins.setPluginGrants('test-plugin', { capabilities: ['workspace.write'], domains: [] }),
|
|
59
|
+
/undeclared/i,
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('permission decisions reject malformed grant and activation values', async () => {
|
|
64
|
+
await plugins.installPlugin(packageBytes())
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
plugins.setPluginGrants('test-plugin', { capabilities: 'network.request', domains: [] }),
|
|
67
|
+
/grant|capabilit/i,
|
|
68
|
+
)
|
|
69
|
+
await assert.rejects(plugins.setPluginSafe('test-plugin', 'true'), /trust|true or false/i)
|
|
70
|
+
await assert.rejects(plugins.setPluginEnabled('test-plugin', 'true'), /activation|true or false/i)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('changing a manifest revokes prior trust and grants', async () => {
|
|
74
|
+
await installAndGrant()
|
|
75
|
+
const changed = { ...baseManifest, domains: ['api.example.test', 'new.example.test'] }
|
|
76
|
+
const result = await plugins.installPlugin(packageBytes(changed))
|
|
77
|
+
assert.equal(result.safe, false)
|
|
78
|
+
assert.equal(result.enabled, false)
|
|
79
|
+
assert.deepEqual(result.grants, { capabilities: [], domains: [] })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('network requests require a separately granted exact domain', async () => {
|
|
83
|
+
await installAndGrant()
|
|
84
|
+
let called = false
|
|
85
|
+
await assert.rejects(
|
|
86
|
+
plugins.requestPluginNetwork('test-plugin', { url: 'https://other.example.test/data' }, {
|
|
87
|
+
fetchImpl: async () => {
|
|
88
|
+
called = true
|
|
89
|
+
return new Response('ok')
|
|
90
|
+
},
|
|
91
|
+
lookupImpl: async () => [{ address: '93.184.216.34' }],
|
|
92
|
+
}),
|
|
93
|
+
/domain|permission/i,
|
|
94
|
+
)
|
|
95
|
+
assert.equal(called, false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('network requests reject redirects instead of following them', async () => {
|
|
99
|
+
await installAndGrant()
|
|
100
|
+
await assert.rejects(
|
|
101
|
+
plugins.requestPluginNetwork('test-plugin', { url: 'https://api.example.test/data' }, {
|
|
102
|
+
fetchImpl: async () => new Response(null, { status: 302, headers: { location: 'https://other.example.test/' } }),
|
|
103
|
+
lookupImpl: async () => [{ address: '93.184.216.34' }],
|
|
104
|
+
}),
|
|
105
|
+
/redirect/i,
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('network responses reject executable and oversized content', async () => {
|
|
110
|
+
await installAndGrant()
|
|
111
|
+
await assert.rejects(
|
|
112
|
+
plugins.requestPluginNetwork('test-plugin', { url: 'https://api.example.test/file' }, {
|
|
113
|
+
fetchImpl: async () => new Response('alert(1)', { headers: { 'content-type': 'application/javascript' } }),
|
|
114
|
+
lookupImpl: async () => [{ address: '93.184.216.34' }],
|
|
115
|
+
}),
|
|
116
|
+
/content type/i,
|
|
117
|
+
)
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
plugins.requestPluginNetwork('test-plugin', { url: 'https://api.example.test/large' }, {
|
|
120
|
+
fetchImpl: async () => new Response('x'.repeat(1_048_577), { headers: { 'content-type': 'text/plain' } }),
|
|
121
|
+
lookupImpl: async () => [{ address: '93.184.216.34' }],
|
|
122
|
+
}),
|
|
123
|
+
/large|size/i,
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('network requests reject private DNS results', async () => {
|
|
128
|
+
await installAndGrant()
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
plugins.requestPluginNetwork('test-plugin', { url: 'https://api.example.test/data' }, {
|
|
131
|
+
fetchImpl: async () => new Response('unexpected'),
|
|
132
|
+
lookupImpl: async () => [{ address: '127.0.0.1' }],
|
|
133
|
+
}),
|
|
134
|
+
/private/i,
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test.after(async () => {
|
|
139
|
+
await rm(dataDir, { recursive: true, force: true })
|
|
140
|
+
})
|