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
7 changes: 3 additions & 4 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,16 @@

- [ ] I read `CONTRIBUTING.md`.
- [ ] Schema changes, if any, updated both runtime schema and `migrations/0001_init.sql`.
- [ ] Schema changes, if any, bumped `STORAGE_SCHEMA_VERSION` in `src/services/storage.ts`.
- [ ] Persistent data changes, if any, updated backup export/import or documented why backup is not needed.
- [ ] User-facing text changes, if any, updated all locale files.
- [ ] Bitwarden client compatibility was considered for sync/API shape changes.
- [ ] No secrets, tokens, private deployment values, or real vault data are included.

## Checks

- [ ] `npx tsc -p tsconfig.json --noEmit`
- [ ] `npx tsc -p webapp/tsconfig.json --noEmit`
- [ ] `npm run i18n:validate`
- [ ] `npm run build`
- [ ] `npm run verify` — type checks for all four tsconfigs, `npm test`, `npm run i18n:validate`, `npm run build`
- [ ] New or changed tests: `npm test` was run at least twice (some cleanup paths are gated on `Math.random()`, so one green run can be luck)

## Notes

Expand Down
13 changes: 8 additions & 5 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ on:
branches:
- "**"

permissions:
contents: read
actions: read
security-events: write
packages: read
# 权限一律在 job 级声明:工作流级声明会让所有 job(含未来的新 job)共享同一套权限,
# 而 security-events: write 只有上传分析结果的那一步需要。
permissions: {}

jobs:
analyze:
name: CodeQL Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
packages: read
security-events: write

strategy:
fail-fast: false
Expand Down
34 changes: 5 additions & 29 deletions .github/workflows/security-extra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,35 +48,11 @@ jobs:
upload-sarif: true
fail-on-vuln: true

pnpm-audit:
name: pnpm audit
runs-on: ubuntu-latest

permissions:
contents: read

steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22

- name: Run pnpm audit
shell: bash
run: |
if [ ! -f pnpm-lock.yaml ]; then
echo "pnpm-lock.yaml not found, skip pnpm audit."
exit 0
fi

corepack enable
corepack prepare pnpm@10 --activate
pnpm audit --audit-level=high
# 说明:这里原本还有一个 `pnpm audit` job,但本仓库使用 npm(仅有
# package-lock.json,无 pnpm-lock.yaml),该 job 的守卫 `if [ ! -f pnpm-lock.yaml ]`
# 必然直接 exit 0 —— 永远不执行任何审计。实测上方 osv job 的
# `scan source --recursive` 会扫到 package-lock.json(321 个包),
# 覆盖范围等价,故移除该冗余 job。

semgrep:
name: Semgrep CE Scan
Expand Down
13 changes: 9 additions & 4 deletions .github/workflows/sync-global-domains.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,24 @@ on:
default: "main"
type: string

permissions:
contents: write
pull-requests: write
# 权限一律在 job 级声明:工作流级声明会让所有 job 共享同一套过宽权限。
permissions: {}

jobs:
sync-global-domains:
runs-on: ubuntu-latest
# 需要建分支/提交(contents)并创建 PR(pull-requests)——
# 这也是本文件的 checkout **不能**加 persist-credentials: false 的原因(与 verify.yml 相反)。
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22
# 与 verify.yml、Cloudflare Workers Builds 共用同一版本来源(.nvmrc)。
node-version-file: '.nvmrc'

- name: Sync generated Bitwarden domains
env:
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Verify

# 说明:本仓库已有一套测试与校验脚本(类型检查 / i18n 对齐 / 通知与 WebAuthn
# 安全测试 / 构建),但在本次补充前,CI 中没有任何一个 workflow 执行它们 ——
# 测试写了却无人自动运行,回归不会被拦截。此外 `pnpm audit` 步骤因项目使用 npm
# (仅有 package-lock.json)而被守卫跳过。此 workflow 补上“验证”这一环。
#
# 另:scripts/security-audit-*.mjs 三个安全回归脚本此前在 CI、npm scripts 与
# 文档中均无引用(死资产),现已通过 `test:security-audit` 纳入 `npm test`。
#
# 本地复现:npm run verify

on:
push:
branches: [main]
pull_request:

# 仅需读取仓库内容
permissions:
contents: read

jobs:
verify:
name: Type check, i18n, tests, build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
# 本 job 不需要 git push,因此不把 GITHUB_TOKEN 写进 .git/config。
# 否则 npm ci 期间任何依赖包的 postinstall 脚本都能读到该令牌。
# 与 codeql.yml / security-extra.yml 保持一致。
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
# 版本来源单一化:.nvmrc 同时被本 workflow 与 Cloudflare Workers Builds
# 的构建镜像读取(官方文档:NODE_VERSION / .nvmrc / .node-version)。
# 24.18.0 是 Cloudflare 构建镜像预装的版本,因此三方零分叉、零下载。
node-version-file: '.nvmrc'
cache: npm
- name: Install dependencies
run: npm ci
- name: Verify (typecheck + i18n + tests + build)
run: npm run verify
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.18.0
62 changes: 62 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,63 @@ For new locales, update:
- `webapp/src/lib/i18n/locales/*`
- `scripts/i18n-utils.cjs`

### Tests

`npm test` runs the whole suite. It needs no external services and no network: the
D1 and R2 bindings are backed by in-process implementations, so the tests execute
**real SQL** against the real schema (including the real shadow tables and the
final swap used by restore).

```sh
npm test
```

Files worth knowing about when adding a test:

- `scripts/lib/test-harness.ts` — shared fixture. Start from
`createSchemaDatabase()` / `insertUser()` instead of building a database by hand;
it also resets the process-scoped statics that would otherwise stop a second
database from getting a schema.
- `scripts/lib/d1-sqlite.ts` — a `D1Database` on Node's built-in `node:sqlite`.
- `scripts/lib/r2-memory.ts` — in-memory attachment bucket.
- `scripts/lib/sql-recorder.ts` — records the statements that were run. Two
counters, do not mix them up: `queries` counts prepared statements (use it for
query-plan assertions) while `roundTrips` counts database round trips (use it
for N+1 assertions). `batch([...])` is N prepares but **one** round trip.
- `scripts/lib/register-cloudflare-stub.mjs` — handler tests must be started as
`tsx --import ./scripts/lib/register-cloudflare-stub.mjs`, because
`cloudflare:workers` cannot be resolved under Node. This is already wired into
every `test:*-handler` script; copy one of them when adding another.

Two rules that exist because each has already produced a false result:

- **Run the suite at least twice** before calling a change green. Some cleanup
paths are gated on `Math.random()` and only run on a fraction of requests, so a
single passing run can be luck. When testing such a path, pin `Math.random` for
the duration of the test rather than hoping the path fires.
- **Never assert that two timestamps differ.** Calling `new Date().toISOString()`
twice within the same millisecond returns the same value. Pin a baseline
timestamp and assert that the new value is greater.

## Recommended Checks

Before opening a pull request, run the same command CI runs:

```sh
npm run verify
```

which is:

```sh
npm run typecheck # tsconfig.json, webapp/, tsconfig.scripts.json, tsconfig.webapp-tests.json
npm run i18n:validate
npm test
npm run build
```

Narrower runs while iterating.

For most backend or shared changes:

```sh
Expand All @@ -126,6 +181,13 @@ npx tsc -p webapp/tsconfig.json --noEmit
npm run build
```

For changes under `scripts/` (tests and tooling):

```sh
npx tsc -p tsconfig.scripts.json --noEmit
npm test
```

For documentation-only changes:

```sh
Expand Down
36 changes: 36 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,42 @@ We aim to acknowledge valid private reports within 72 hours, investigate the iss

Please do not publicly disclose vulnerability details before a fix or mitigation is available.

## Administrator Bootstrap and Role Assignment

NodeWarden has no separate setup step or setup token. Administrator privilege is
assigned as follows.

**First account.** The first account that registers on an instance is granted the
`admin` role, and the instance is then marked as registered. This is recorded in
the security audit log as `user.register.first_admin`.

**Later accounts.** After the first account exists, registration requires an
invite code (`Invite code is required`, HTTP 403) and the new account gets the
default `user` role. These are recorded as `user.register.invite`. Registration
can therefore not be used to obtain administrator privilege on an existing
instance.

**Recovery when no administrator exists.** If an instance ends up with no account
holding the `admin` role — for example the last administrator account was deleted
— the database bootstrap promotes the **earliest-created** account back to `admin`
on its next schema initialization. This is a system action with no actor, and is
recorded in the security audit log as `user.bootstrap.admin_promoted`.

Two properties of that recovery path are worth knowing:

* **Administrator accounts are not protected against deletion.** NodeWarden does
not block deleting the last administrator. The bootstrap exists so an instance
cannot become permanently unmanageable, but it is not a substitute for
administrative care on a multi-user instance.
* **The account that regains the role is selected by account creation time, not
by trust.** On a multi-user instance, make sure you intend to delete an
administrator account before doing so.

The bootstrap only runs when the runtime schema is initialized or re-initialized
(for example after a schema version change), not on every request. Operators who
need tighter control over administrator assignment should edit the `users.role`
column directly and treat the bootstrap as a recovery mechanism only.

## Supported Versions

Security fixes are generally provided for the latest release and the latest code on the default branch.
Expand Down
38 changes: 38 additions & 0 deletions migrations/0001_init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ CREATE TABLE IF NOT EXISTS users (
verify_devices INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT,
totp_recovery_code TEXT,
-- YubiKey OTP:最多 5 个密钥槽 + NFC 开关。
-- 与 storage-schema.ts 的运行时定义保持一致(原先仅运行时建表包含这 6 列)。
yubikey_key1 TEXT,
yubikey_key2 TEXT,
yubikey_key3 TEXT,
yubikey_key4 TEXT,
yubikey_key5 TEXT,
yubikey_nfc INTEGER NOT NULL DEFAULT 0,
api_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
Expand Down Expand Up @@ -140,6 +148,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
-- 清理用:DELETE FROM refresh_tokens WHERE expires_at < ?
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at);

CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY,
Expand Down Expand Up @@ -222,6 +232,9 @@ CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending
ON auth_requests(user_id, approved, response_date, authentication_date, creation_date);
CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending
ON auth_requests(user_id, request_device_identifier, creation_date);
-- 清理用:DELETE FROM auth_requests WHERE creation_date < ?(不带 user_id)
CREATE INDEX IF NOT EXISTS idx_auth_requests_creation_date
ON auth_requests(creation_date);

CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
token TEXT PRIMARY KEY,
Expand All @@ -232,6 +245,9 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
);
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier);
-- 清理用:DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_expires
ON trusted_two_factor_device_tokens(expires_at);

CREATE TABLE IF NOT EXISTS totp_login_replays (
user_id TEXT NOT NULL,
Expand Down Expand Up @@ -281,6 +297,10 @@ CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
ON webauthn_challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope
ON webauthn_challenges(user_id, scope);
-- 清理用的语句已拆成两条(见 storage-account-passkey-repo.ts):
-- `WHERE expires_at < ? OR used_at IS NOT NULL` 里的 OR 会让索引全部失效。
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_used_at
ON webauthn_challenges(used_at);

-- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts_ip (
Expand All @@ -289,8 +309,26 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
locked_until INTEGER,
updated_at INTEGER NOT NULL
);
-- 清理用(storage-schema.ts 的 maybeCleanupLoginAttempts):
-- DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_updated_at
ON login_attempts_ip(updated_at);

CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
jti TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
-- 清理用:DELETE FROM used_attachment_download_tokens WHERE expires_at < ?
CREATE INDEX IF NOT EXISTS idx_used_attachment_download_tokens_expires
ON used_attachment_download_tokens(expires_at);

-- 严格限流预算(storage-schema.ts 的 RateLimitService 使用)。
-- 与 storage-schema.ts 的运行时定义保持一致(原先仅运行时建表包含此表)。
CREATE TABLE IF NOT EXISTS rate_limit_buckets (
bucket_key TEXT PRIMARY KEY,
count INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires
ON rate_limit_buckets(expires_at);
Loading