Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/backend/pkg/permission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { getActualPath, can, canWrite, UserRole } from "./permission"

function user(base_path: string, extra: any = {}) {
return {
id: 2,
username: "alice",
role: UserRole.GENERAL,
permission: 0,
base_path,
...extra,
}
}

test("getActualPath: normal paths still map into base_path", () => {
const u = user("/jail")
assert.equal(getActualPath(u, "/"), "/jail")
assert.equal(getActualPath(u, "/sub"), "/jail/sub")
assert.equal(getActualPath(u, "sub"), "/jail/sub")
assert.equal(getActualPath(u, "/a/b/c"), "/jail/a/b/c")
})

test("getActualPath: no base_path behaves as before (full access)", () => {
assert.equal(getActualPath(null, "/x/../y"), "/x/../y")
assert.equal(getActualPath(user("/"), "/x/../y"), "/x/../y")
assert.equal(getActualPath(user(""), "/x/y"), "/x/y")
})

test("getActualPath: share paths pass through untouched", () => {
const u = user("/jail")
assert.equal(getActualPath(u, "/@s/abc/file"), "/@s/abc/file")
})

test("Security(P0): '..' cannot escape the user base_path", () => {
const u = user("/jail")
// "/jail/../secret" collapses to "/secret" which is outside "/jail"
assert.equal(getActualPath(u, "/../secret"), "/jail")
assert.equal(getActualPath(u, "/.."), "/jail")
assert.equal(getActualPath(u, "/../../.."), "/jail")
assert.equal(getActualPath(u, "/sub/../../secret"), "/jail")
})

test("Security(P0): encoded traversal cannot escape the user base_path", () => {
const u = user("/jail")
// single-encoded dots
assert.equal(getActualPath(u, "/%2e%2e/secret"), "/jail")
// double-encoded dots
assert.equal(getActualPath(u, "/%252e%252e/secret"), "/jail")
// encoded slashes splitting ".." across segments
assert.equal(getActualPath(u, "/sub%2f..%2f..%2fsecret"), "/jail")
// encoded backslash traversal
assert.equal(getActualPath(u, "/..%5csecret"), "/jail")
// dot runs (>2 dots) normalize to ".." before folding
assert.equal(getActualPath(u, "/...."), "/jail")
assert.equal(getActualPath(u, "/a...b"), "/jail/a..b")
// null byte: "..\0" is not a parent segment so nothing escapes here;
// the request is later rejected by resolvePath's illegal-character check.
assert.equal(getActualPath(u, "/..%00/secret"), "/jail/..\0/secret")
})

test("Security(P0): intra-base_path '..' still resolves inside the jail", () => {
const u = user("/jail")
// collapses to /jail/sub2 -> allowed
assert.equal(getActualPath(u, "/sub/../sub2"), "/jail/sub2")
assert.equal(getActualPath(u, "/sub/./other"), "/jail/sub/other")
})

test("Security(P0): backslashes and duplicate slashes are normalized", () => {
const u = user("/jail")
assert.equal(getActualPath(u, "\\sub\\x"), "/jail/sub/x")
assert.equal(getActualPath(u, "//sub///x"), "/jail/sub/x")
})

test("Security(P0): a misconfigured base_path containing '..' is collapsed", () => {
const u = user("/a/../b")
assert.equal(getActualPath(u, "/"), "/b")
assert.equal(getActualPath(u, "/file"), "/b/file")
})

test("can(): disabled / guest / admin semantics", () => {
assert.equal(can(user("/", { role: UserRole.ADMIN }), 0), true)
assert.equal(can(user("/", { disabled: true, role: UserRole.ADMIN }), 0), false)
assert.equal(can(user("/", { role: UserRole.GUEST }), 0), false)
assert.equal(can(null, 0), false)
})

test("canWrite(): WRITE_CONTENT bit (1<<3) for general users", () => {
assert.equal(canWrite(user("/", { permission: 8 })), true)
assert.equal(canWrite(user("/", { permission: 0 })), false)
})
75 changes: 74 additions & 1 deletion src/backend/pkg/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,64 @@ export function canRemove(user?: UserPermissionObj | null): boolean {
return can(user, PermissionBit.DELETE)
}

/**
* Normalize a virtual path exactly the way resolvePath() does
* (decode loop, separator / dot-run collapsing) and then collapse
* "." / ".." segments with the same stack rules (a leading ".." that
* pops an empty stack is clamped to the root instead of escaping
* upward).
*
* Keeping both implementations in lockstep is essential: resolvePath
* decodes the path *after* base_path concatenation happened here, so
* any encoded traversal ("%2e%2e", "%2f", "....", ...) must already be
* folded away at this layer — otherwise it would only surface (and
* escape) inside resolvePath.
*/
function normalizeAndCollapse(path: string): string {
let p = String(path || "")

// Mirror resolvePath step 1+2: decode until stable (initial decode +
// up to 3 more rounds) to defeat single/double-encoded traversal.
try {
p = decodeURIComponent(p)
} catch {
// keep raw value on malformed input
}
let prev = ""
let attempts = 0
while (p !== prev && attempts < 3) {
prev = p
try {
const decoded = decodeURIComponent(p)
if (decoded === p) break
p = decoded
attempts++
} catch {
break
}
}

// Mirror resolvePath step 3: separator and dot-run normalization.
p = p
.replace(/\\/g, "/")
.replace(/%5c/gi, "/")
.replace(/%2f/gi, "/")
.replace(/\.{3,}/g, "..")
.replace(/\/+/g, "/")

// Mirror resolvePath step "Normalize .. / .": stack folding.
const stack: string[] = []
for (const seg of p.split("/")) {
if (seg === "" || seg === ".") continue
if (seg === "..") {
stack.pop()
continue
}
stack.push(seg)
}
return "/" + stack.join("/")
}

/**
* 计算用户请求路径对应的实际存储路径(结合用户的根目录 base_path):
* 1. 忽略以 /@s 开头的分享虚拟路径
Expand All @@ -87,6 +145,9 @@ export function canRemove(user?: UserPermissionObj | null): boolean {
* - reqPath = "/" 或 "" -> "/photos"
* - reqPath = "/sub" -> "/photos/sub"
* - reqPath = "sub" -> "/photos/sub"
* 4. 拼接后折叠 "." / ".." 段(安全加固):任何逃出 base_path 边界的请求
* (如 base_path="/x" 时的 "/../secret")都被钳制回用户根目录,
* 防止用户通过 .. 段越权访问其他存储挂载点。
*/
export function getActualPath(
user?: UserPermissionObj | null,
Expand All @@ -108,11 +169,23 @@ export function getActualPath(
if (basePath.endsWith("/") && basePath.length > 1) {
basePath = basePath.replace(/\/+$/, "")
}
// Guard against a misconfigured base_path that itself contains "..".
basePath = normalizeAndCollapse(basePath) || "/"

const cleanReq = p.startsWith("/") ? p : `/${p}`
if (cleanReq === "/") {
return basePath
}

return `${basePath}${cleanReq}`
// FIX(P0): 拼接后按 resolvePath 同样的规则(解码 + 规范化 + 折叠)收敛。
// 此前仅做字符串拼接,"/x/../secret"、"/%2e%2e/secret"、"sub%2f..%2f.." 等
// 会在 resolvePath 的解码/折叠中把 base_path 前缀"弹掉",导致 base_path
// 监禁被完全绕过(跨存储越权读/写/删)。
const joined = normalizeAndCollapse(`${basePath}${cleanReq}`)
if (joined === basePath || joined.startsWith(`${basePath}/`)) {
return joined
}
// Escaped the user base path (e.g. "/x/../secret" -> "/secret"):
// clamp back to the user root instead of leaking outside storage.
return basePath
}
4 changes: 3 additions & 1 deletion src/backend/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ export async function authUserFromReq(
const user = db.users.find(
(u: any) => u.id === payload.id || u.username === payload.username,
)
if (!user) return null
// FIX(P0): disabled 用户必须与 getUserFromContext 行为一致被拒绝,
// 否则被禁用用户的存量 JWT 仍可通过 /s3、/dav(Bearer) 等入口访问。
if (!user || user.disabled) return null
return { db, user }
} catch {
return null
Expand Down
74 changes: 62 additions & 12 deletions src/backend/server/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import {
removeItems,
} from "../internal/op/storage"
import { safeErrorMessage } from "../pkg/errs"
import {
canWrite,
canRemove,
getActualPath,
isGuest,
} from "../pkg/permission"

/**
* S3 网关(简化版,挂载于 /s3/*)。
Expand All @@ -16,6 +22,12 @@ import { safeErrorMessage } from "../pkg/errs"
* 后续增强(Worker 环境 S3 网关性能受限,优先保证 API 契约一致)。
*
* URL 结构:/s3/{bucket}/{objectKey},bucket 映射到存储挂载路径。
*
* FIX(P0):所有操作统一经过与 /api/fs/* 一致的授权链:
* 1. 仅非 guest 的已认证用户可用;
* 2. 写操作要求 WRITE_CONTENT / DELETE 权限位;
* 3. 虚拟路径必须经 getActualPath() 收敛到用户 base_path 内,
* 防止任意用户读/写/删整个虚拟文件系统。
*/

export const s3Router = new Hono()
Expand Down Expand Up @@ -43,7 +55,15 @@ function parseS3Path(c: any): { bucket: string; key: string } {
const p = pathname.replace(/^\/s3\/?/, "")
const parts = p.split("/").filter(Boolean)
const bucket = parts[0] || ""
const key = parts.slice(1).map(decodeURIComponent).join("/")
// 畸形 % 序列不得让网关 500:退回原始段。
const safeDecode = (s: string) => {
try {
return decodeURIComponent(s)
} catch {
return s
}
}
const key = parts.slice(1).map(safeDecode).join("/")
return { bucket, key }
}

Expand All @@ -55,7 +75,16 @@ function bucketToPath(bucket: string): string {

async function authUser(c: any): Promise<any | null> {
const auth = await authUserFromReq(c)
return auth ? auth.user : null
if (!auth) return null
const user = auth.user
// S3 网关仅面向实体用户(guest 无 token,防御性双保险)。
if (isGuest(user)) return null
return user
}

/** 将请求的虚拟路径收敛到用户 base_path 内 */
function resolveS3VirtualPath(user: any, virtualPath: string): string {
return getActualPath(user, virtualPath)
}

const getCtx = (c: any) => {
Expand All @@ -69,12 +98,13 @@ const getCtx = (c: any) => {
}
}

// GET /s3/ → ListBuckets
s3Router.get("/", async (c) => {
const user = await authUser(c)
if (!user) return s3Error("AccessDenied", "Authentication required", 403)
// ListBuckets:仅列出用户 base_path 内的一级目录。
// 注:Hono route 挂载后 GET /s3/(带尾斜杠)不会命中 get("/") 路由,
// 而是落到 get("/*"),因此抽成函数供两个路由共用。
async function listBuckets(c: any, user: any) {
try {
const res = await listItems("/", getCtx(c))
// FIX(P0):bucket 列表收敛到用户 base_path 内。
const res = await listItems(resolveS3VirtualPath(user, "/"), getCtx(c))
const buckets = (res.content || [])
.filter((it: any) => it.is_dir)
.map(
Expand All @@ -96,15 +126,25 @@ ${buckets}
} catch (e: any) {
return s3Error("InternalError", safeErrorMessage(e), 500)
}
}

// GET /s3 → ListBuckets
s3Router.get("/", async (c) => {
const user = await authUser(c)
if (!user) return s3Error("AccessDenied", "Authentication required", 403)
return listBuckets(c, user)
})

// GET /s3/{bucket}/{key} → GetObject
s3Router.get("/*", async (c) => {
const user = await authUser(c)
if (!user) return s3Error("AccessDenied", "Authentication required", 403)
const { bucket, key } = parseS3Path(c)
if (!bucket || !key) return s3Error("NoSuchKey", "NoSuchKey", 404)
const virtualPath = `/${bucket}/${key}`
// GET /s3/(带尾斜杠):无 bucket,视为 ListBuckets 请求。
if (!bucket) return listBuckets(c, user)
if (!key) return s3Error("NoSuchKey", "NoSuchKey", 404)
// FIX(P0):路径收敛到用户 base_path,禁止跨存储越权读取。
const virtualPath = resolveS3VirtualPath(user, `/${bucket}/${key}`)
try {
const { item, rawUrl } = await getItem(virtualPath, getCtx(c))
if (!item) return s3Error("NoSuchKey", "NoSuchKey", 404)
Expand All @@ -125,7 +165,10 @@ s3Router.on("HEAD", "/*", async (c) => {
const { bucket, key } = parseS3Path(c)
if (!bucket || !key) return s3Error("NoSuchKey", "NoSuchKey", 404)
try {
const { item } = await getItem(`/${bucket}/${key}`, getCtx(c))
const { item } = await getItem(
resolveS3VirtualPath(user, `/${bucket}/${key}`),
getCtx(c),
)
if (!item || item.is_dir) return s3Error("NoSuchKey", "NoSuchKey", 404)
const headers: Record<string, string> = {
"Content-Length": String(item.size || 0),
Expand All @@ -142,11 +185,14 @@ s3Router.on("HEAD", "/*", async (c) => {
s3Router.put("/*", async (c) => {
const user = await authUser(c)
if (!user) return s3Error("AccessDenied", "Authentication required", 403)
// FIX(P0):上传需 WRITE_CONTENT 权限位,与 /api/fs/put 一致。
if (!canWrite(user))
return s3Error("AccessDenied", "Write permission required", 403)
const { bucket, key } = parseS3Path(c)
if (!bucket || !key) return s3Error("InvalidArgument", "Invalid bucket/key", 400)
try {
const buffer = Buffer.from(await c.req.arrayBuffer())
await putItem(`/${bucket}/${key}`, buffer, getCtx(c))
await putItem(resolveS3VirtualPath(user, `/${bucket}/${key}`), buffer, getCtx(c))
return new Response(null, {
status: 200,
headers: { ETag: `"${Date.now().toString(16)}"` },
Expand All @@ -160,10 +206,14 @@ s3Router.put("/*", async (c) => {
s3Router.delete("/*", async (c) => {
const user = await authUser(c)
if (!user) return s3Error("AccessDenied", "Authentication required", 403)
// FIX(P0):删除需 DELETE 权限位,与 /api/fs/remove 一致。
if (!canRemove(user))
return s3Error("AccessDenied", "Delete permission required", 403)
const { bucket, key } = parseS3Path(c)
if (!bucket || !key) return s3Error("InvalidArgument", "Invalid bucket/key", 400)
const idx = key.lastIndexOf("/")
const dir = idx >= 0 ? `/${bucket}/${key.slice(0, idx)}` : `/${bucket}`
const resolvedDir = resolveS3VirtualPath(user, idx >= 0 ? `/${bucket}/${key.slice(0, idx)}` : `/${bucket}`)
const dir = resolvedDir
const name = idx >= 0 ? key.slice(idx + 1) : key
try {
await removeItems(dir, [name], getCtx(c))
Expand Down
Loading