Files
bare-operating-system/packages/bare-os-booter/lib/bare-openssh-sftp.js
T
Raven Scott 36492de3d9 bare-ssh2 parses SFTP handles via bufferSlice/safer-buffer — handles are often
Uint8Array views that fail Buffer.isBuffer; READ/CLOSE/FSTAT/WRITE/READDIR were
rejecting every request with SSH_FX_FAILURE. Add sftpHandleId() using DataView.

OPEN: fix inner try indentation. CHANGELOG: note handle decoding + earlier SFTP
hardening (vfs normalize, enqueueSftpReply, Protocol/SFTP DATA coerce).
2026-04-21 18:22:32 -04:00

585 lines
17 KiB
JavaScript

/**
* Minimal SFTP over Bare OS VFS (bare-ssh2 SFTP server).
* Readable paths match interactive shell / VFS (home, /mnt, system image, pseudo, …).
* Writes are denied on typical read-only system prefixes; other paths defer to VFS ACLs.
*/
import {
S_IFDIR,
S_IFLNK,
S_IFREG,
formatLsMtime,
formatModeString
} from './vfs-posix-meta.js'
/**
* @typedef {{ type: string, mode?: number, size?: number, uid?: number, gid?: number, user?: string, group?: string, mtimeMs?: number }} VfsStatRow
*/
/**
* Logical paths visible over SFTP (parity with shell `createVfs` routing).
* @param {string} abs resolved absolute logical path
* @param {string} homeLogical e.g. /home/guest
*/
export function bareOsSftpAllowedLogicalPath(abs, homeLogical) {
const h = String(homeLogical || '/').replace(/\/+$/, '') || '/'
const a = String(abs || '').replace(/\/+/g, '/') || '/'
if (a === h || a.startsWith(h + '/')) return true
/** @type {string[]} */
const roots = [
'/mnt',
'/media',
'/mount',
'/mirror',
'/bin',
'/boot',
'/lib',
'/etc',
'/usr',
'/var',
'/proc',
'/sys',
'/dev',
'/run',
'/tmp',
'/snapshots',
'/root',
'/home',
'/opt'
]
for (const pre of roots) {
if (a === pre || a.startsWith(pre + '/')) return true
}
return false
}
/**
* Deny OPEN when the client requests mutating access on typical system-image paths.
* Personal home, /tmp, /var, /root, and /mnt are left to VFS (mounts may be writable).
* @param {string} abs
* @param {string} homeLogical
* @param {number} flags SFTP OPEN_MODE bitmask
* @param {{ READ: number, WRITE: number, APPEND: number, CREAT: number, TRUNC: number }} OPEN_MODE
*/
export function bareOsSftpDeniesWriteOpen(abs, homeLogical, flags, OPEN_MODE) {
const h = String(homeLogical || '/').replace(/\/+$/, '') || '/'
const a = String(abs || '').replace(/\/+/g, '/') || '/'
const wantMutate =
!!(flags & OPEN_MODE.WRITE) ||
!!(flags & OPEN_MODE.APPEND) ||
!!(flags & OPEN_MODE.CREAT) ||
!!(flags & OPEN_MODE.TRUNC)
if (!wantMutate) return false
if (a === h || a.startsWith(h + '/')) return false
if (a === '/tmp' || a.startsWith('/tmp/')) return false
if (a === '/var' || a.startsWith('/var/')) return false
if (a === '/root' || a.startsWith('/root/')) return false
const readOnlyRoots = [
'/bin',
'/boot',
'/lib',
'/etc',
'/usr',
'/proc',
'/sys',
'/dev',
'/run',
'/snapshots',
'/opt',
'/mirror'
]
for (const pre of readOnlyRoots) {
if (a === pre || a.startsWith(pre + '/')) return true
}
return false
}
/**
* @param {VfsStatRow} st
* @param {number} [sizeOverride]
*/
function vfsStatToSftpAttrs(st, sizeOverride) {
const t = st.type
const isDir = t === 'directory'
const isLink = t === 'symlink'
const mode =
(isDir ? S_IFDIR : isLink ? S_IFLNK : S_IFREG) |
((typeof st.mode === 'number' ? st.mode : 0) & 0o777)
const sz =
sizeOverride !== undefined && sizeOverride !== null
? Number(sizeOverride)
: Number(st.size || 0)
const mtimeMs =
typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
? st.mtimeMs
: Date.now()
return {
mode,
uid: typeof st.uid === 'number' ? st.uid : 1000,
gid: typeof st.gid === 'number' ? st.gid : 1000,
size: sz,
atime: mtimeMs,
mtime: mtimeMs
}
}
/**
* @param {VfsStatRow} st
* @param {string} filename
* @param {number} [sizeOverride]
*/
function vfsStatToSftpLongname(st, filename, sizeOverride) {
const t = st.type
const modeStr = formatModeString(
typeof st.mode === 'number' ? st.mode : 0o644,
t === 'directory' ? 'directory' : t === 'symlink' ? 'symlink' : 'file'
)
const u = String(st.user || 'user')
const g = String(st.group || 'user')
const sz =
sizeOverride !== undefined && sizeOverride !== null
? Number(sizeOverride)
: Number(st.size || 0)
const mtimeMs =
typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
? st.mtimeMs
: Date.now()
const mt = formatLsMtime(mtimeMs)
return `${modeStr} 1 ${u} ${g} ${String(sz).padStart(8, ' ')} ${mt} ${filename}`
}
/**
* @param {string} dirAbs
* @param {string} name
*/
function joinDirEntry(dirAbs, name) {
const d = String(dirAbs || '/').replace(/\/+$/, '') || '/'
const n = String(name || '')
if (!n) return d
return d === '/' ? '/' + n : d + '/' + n
}
/**
* Coerce vfs.readFile results for SFTP OPEN/READ. Empty string is falsy and used to make
* READ return SSH_FX_FAILURE; ArrayBuffer and non-Buffer views may lack `.subarray`.
* @param {unknown} x
* @returns {Buffer | null}
*/
function normalizeVfsBytesForSftp(x) {
if (x == null) return null
if (typeof x === 'string') return Buffer.from(x, 'utf8')
if (Buffer.isBuffer(x)) return x
if (x instanceof ArrayBuffer) return Buffer.from(x)
if (ArrayBuffer.isView(x))
return Buffer.from(x.buffer, x.byteOffset, x.byteLength)
return null
}
/**
* SFTP inbound handles come from bare-ssh2 bufferSlice → often Uint8Array / safer-buffer views
* that do not satisfy `Buffer.isBuffer` (see vendor utils.js bufferSlice comment).
* @param {unknown} h
* @returns {number | null}
*/
function sftpHandleId(h) {
if (h == null) return null
if (Buffer.isBuffer(h) && h.length >= 4) return h.readUInt32BE(0)
if (ArrayBuffer.isView(h) && h.byteLength >= 4) {
return new DataView(h.buffer, h.byteOffset, 4).getUint32(0, false)
}
return null
}
/**
* @param {import('events').EventEmitter} sftp
* @param {Record<string, unknown>} vfs
* @param {string} homeLogical absolute logical home (e.g. /home/guest)
* @param {(p: string) => string} resolveLogical
* @param {{ OPEN_MODE: Record<string, number>, STATUS_CODE: Record<string, number> }} sftpConsts
*/
export function attachBareOsSftp(
sftp,
vfs,
homeLogical,
resolveLogical,
sftpConsts
) {
const { OPEN_MODE, STATUS_CODE } = sftpConsts
/** @type {Map<number, { path: string, buf?: Buffer | null, pos: number, write?: boolean }>} */
const handles = new Map()
/** @type {Map<number, { path: string, entries?: string[], idx: number }>} */
const dirs = new Map()
/** Single counter so directory and file handles never share an id */
let nextHandleId = 1
/**
* Strict SFTP clients (e.g. FileZilla) expect outbound replies in the same order as
* requests were received; overlapping async handlers otherwise reorder packets and
* trigger "request ID mismatch". After serializing replies here, a rejected handler
* that sent no packet could stall clients; thrown errors during SFTP encode have been
* seen as native `bad_optional_access` (-fno-exceptions) killing sshd — always emit
* STATUS FAILURE on handler failure.
*/
/** @type {Promise<void>} */
let sftpReplyChain = Promise.resolve()
/**
* @param {number} reqid
* @param {() => void | Promise<void>} fn
*/
function enqueueSftpReply(reqid, fn) {
const next = sftpReplyChain.then(async () => {
try {
await Promise.resolve().then(fn)
} catch {
try {
sftp.status(reqid, STATUS_CODE.FAILURE)
} catch {
/* ignore */
}
}
})
sftpReplyChain = next.catch(() => {})
}
function ok(reqid) {
sftp.status(reqid, STATUS_CODE.OK)
}
/**
* @param {string} p
* @returns {string | null}
*/
function mapPath(p) {
const norm = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
let logical
if (norm === '.' || norm === '') logical = homeLogical
else if (norm.startsWith('/')) logical = resolveLogical(norm)
else logical = resolveLogical(`${homeLogical}/${norm}`.replace(/\/+/g, '/'))
const abs = logical
if (!bareOsSftpAllowedLogicalPath(abs, homeLogical)) return null
return abs
}
async function statLike(reqid, p, _isLstat) {
const abs = mapPath(p)
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
try {
const st = await vfs.lstat(abs)
if (!st) return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
const attrs = vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), undefined)
sftp.attrs(reqid, attrs)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
}
/**
* @param {string} abs already-resolved logical path (no mapPath)
*/
async function statAbsolute(reqid, abs, sizeOverride) {
try {
const st = await vfs.lstat(abs)
if (!st) return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
const attrs = vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), sizeOverride)
sftp.attrs(reqid, attrs)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
}
sftp.on('REALPATH', (reqid, p) => {
enqueueSftpReply(reqid, async () => {
const abs = mapPath(p) || homeLogical
try {
const st = await vfs.lstat(abs)
const attrs = st
? vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), undefined)
: {}
const name = [
{
filename: abs,
longname: st
? vfsStatToSftpLongname(
/** @type {VfsStatRow} */ (st),
abs.split('/').pop() || abs,
undefined
)
: 'drwxr-xr-x 1 user user 0 Jan 1 00:00 ' + abs,
attrs
}
]
sftp.name(reqid, name)
} catch {
sftp.name(reqid, [
{
filename: abs,
longname: 'drwxr-xr-x 1 user user 0 Jan 1 00:00 ' + abs,
attrs: {}
}
])
}
})
})
sftp.on('STAT', (reqid, p) => {
enqueueSftpReply(reqid, () => statLike(reqid, p, false))
})
sftp.on('LSTAT', (reqid, p) => {
enqueueSftpReply(reqid, () => statLike(reqid, p, true))
})
sftp.on('OPENDIR', (reqid, p) => {
enqueueSftpReply(reqid, async () => {
const abs = mapPath(p)
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
try {
const list = await vfs.readdir(abs)
if (!Array.isArray(list)) return sftp.status(reqid, STATUS_CODE.FAILURE)
const h = Buffer.alloc(4)
const id = nextHandleId++
h.writeUInt32BE(id, 0)
dirs.set(id, { path: abs, entries: list, idx: 0 })
sftp.handle(reqid, h)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
})
})
sftp.on('READDIR', (reqid, handle) => {
enqueueSftpReply(reqid, async () => {
const id = sftpHandleId(handle)
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
const d = dirs.get(id)
if (!d || !d.entries) return sftp.status(reqid, STATUS_CODE.FAILURE)
if (d.idx >= d.entries.length) return sftp.status(reqid, STATUS_CODE.EOF)
const chunk = d.entries.slice(d.idx, d.idx + 32)
d.idx += chunk.length
const names = []
for (const ent of chunk) {
const childAbs = joinDirEntry(d.path, ent)
try {
const st = await vfs.lstat(childAbs)
if (!st) {
names.push({
filename: ent,
longname: '-rw-r--r-- 1 user user 0 Jan 1 00:00 ' + ent,
attrs: {}
})
continue
}
const bl = /** @type {VfsStatRow} */ (st)
names.push({
filename: ent,
longname: vfsStatToSftpLongname(bl, ent, undefined),
attrs: vfsStatToSftpAttrs(bl, undefined)
})
} catch {
names.push({
filename: ent,
longname: '-rw-r--r-- 1 user user 0 Jan 1 00:00 ' + ent,
attrs: {}
})
}
}
sftp.name(reqid, names)
})
})
sftp.on('OPEN', (reqid, filename, flags) => {
enqueueSftpReply(reqid, async () => {
const abs = mapPath(filename)
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
if (bareOsSftpDeniesWriteOpen(abs, homeLogical, flags, OPEN_MODE)) {
return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
}
const wantRead = !!(flags & OPEN_MODE.READ)
const wantWrite =
!!(flags & OPEN_MODE.WRITE) || !!(flags & OPEN_MODE.APPEND)
const wantCreat = !!(flags & OPEN_MODE.CREAT)
try {
let buf = null
if (!wantCreat) {
try {
buf = await vfs.readFile(abs)
} catch {
buf = null
}
}
buf = normalizeVfsBytesForSftp(buf)
if (buf == null && !wantCreat && wantRead) {
return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
}
if (buf == null && wantCreat) buf = Buffer.alloc(0)
if (buf == null) buf = Buffer.alloc(0)
const h = Buffer.alloc(4)
const id = nextHandleId++
h.writeUInt32BE(id, 0)
handles.set(id, {
path: abs,
buf,
pos: 0,
write: wantWrite || wantCreat
})
sftp.handle(reqid, h)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
})
})
sftp.on('READ', (reqid, handle, offset, length) => {
enqueueSftpReply(reqid, () => {
try {
const id = sftpHandleId(handle)
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
const f = handles.get(id)
if (!f || f.buf == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
const raw = f.buf
const body =
Buffer.isBuffer(raw) || ArrayBuffer.isView(raw)
? raw
: typeof raw === 'string'
? Buffer.from(raw, 'utf8')
: null
if (!body) return sftp.status(reqid, STATUS_CODE.FAILURE)
const off = Number(offset)
const len = Number(length)
if (!Number.isFinite(off) || !Number.isFinite(len)) {
return sftp.status(reqid, STATUS_CODE.FAILURE)
}
const end = Math.min(off + len, body.length)
if (off >= body.length) return sftp.status(reqid, STATUS_CODE.EOF)
let slice = body.subarray(off, end)
if (!Buffer.isBuffer(slice)) {
slice = Buffer.from(slice.buffer, slice.byteOffset, slice.byteLength)
}
sftp.data(reqid, slice)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
})
})
sftp.on('FSTAT', (reqid, handle) => {
enqueueSftpReply(reqid, async () => {
const id = sftpHandleId(handle)
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
const f = handles.get(id)
if (f) {
const hint = f.buf ? f.buf.length : undefined
return statAbsolute(reqid, f.path, hint)
}
const d = dirs.get(id)
if (d && d.path) return statAbsolute(reqid, d.path, undefined)
sftp.status(reqid, STATUS_CODE.FAILURE)
})
})
sftp.on('WRITE', (reqid, handle, offset, data) => {
enqueueSftpReply(reqid, async () => {
const id = sftpHandleId(handle)
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
const f = handles.get(id)
if (!f || !f.write) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
try {
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data)
const prev = f.buf ? new Uint8Array(f.buf) : new Uint8Array(0)
const need = offset + u8.length
const out = new Uint8Array(Math.max(prev.length, need))
out.set(prev)
out.set(u8, offset)
f.buf = out
await vfs.writeFile(f.path, out)
ok(reqid)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
})
})
sftp.on('CLOSE', (reqid, handle) => {
enqueueSftpReply(reqid, () => {
const id = sftpHandleId(handle)
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
handles.delete(id)
dirs.delete(id)
ok(reqid)
})
})
sftp.on('REMOVE', (reqid, p) => {
enqueueSftpReply(reqid, async () => {
const abs = mapPath(p)
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
try {
await vfs.unlink(abs)
ok(reqid)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
})
})
sftp.on('MKDIR', (reqid, p) => {
enqueueSftpReply(reqid, async () => {
const abs = mapPath(p)
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
try {
await vfs.mkdir(abs, { recursive: true })
ok(reqid)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
})
})
sftp.on('RMDIR', (reqid, p) => {
enqueueSftpReply(reqid, async () => {
const abs = mapPath(p)
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
try {
await vfs.rmdir(abs)
ok(reqid)
} catch {
sftp.status(reqid, STATUS_CODE.FAILURE)
}
})
})
sftp.on('SETSTAT', (reqid) => {
enqueueSftpReply(reqid, () => {
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
})
})
sftp.on('FSETSTAT', (reqid) => {
enqueueSftpReply(reqid, () => {
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
})
})
sftp.on('EXTENDED', (reqid) => {
enqueueSftpReply(reqid, () => {
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
})
})
sftp.on('RENAME', (reqid) => {
enqueueSftpReply(reqid, () => {
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
})
})
sftp.on('READLINK', (reqid) => {
enqueueSftpReply(reqid, () => {
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
})
})
sftp.on('SYMLINK', (reqid) => {
enqueueSftpReply(reqid, () => {
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
})
})
}