项目地址:https://github.com/nap0o/2fauth-worker
Hi, thanks for open‑sourcing this project — I did a quick security review of the backend + frontend and wanted to share some findings/suggestions. This is written from the perspective of treating 2fauth-worker as a “2FA password manager”, i.e. very high sensitivity by design.
1. OAuth state only validated on the frontend (not on the server)
Files:
frontend/src/features/auth/views/login.vuefrontend/src/features/auth/views/oauthCallback.vuebackend/src/features/auth/authRoutes.tsbackend/src/features/auth/authService.ts
Current flow (simplified):
GET /api/oauth/authorize/:providergenerates a randomstateand returns it to the SPA:const state = crypto.randomUUID(); const result = await provider.getAuthorizeUrl(state); return { url: result.url, state, codeVerifier: result.codeVerifier };- The frontend stores
stateinlocalStorageand redirects to the provider:localStorage.setItem('oauth_state', data.state) localStorage.setItem('oauth_provider', providerId) // ... window.location.href = data.authUrl - On callback, the frontend does validate
state:const savedState = localStorage.getItem('oauth_state') if (!savedState || savedState !== state) { errorMsg.value = '安全警告:State 校验失败,请求可能被篡改' return } - But on the server,
AuthService.handleOAuthCallbackignoresstateentirely:if (providerName === 'telegram') { const searchParams = new URLSearchParams(); Object.entries(body).forEach(([key, value]) => { ... }); params = searchParams; } else { if (!body.code) throw new AppError('Missing OAuth code', 400); params = body.code; // state is not used here } const userInfo = await provider.handleCallback(params, body.codeVerifier);
So OAuth state is only enforced in the browser, not at the backend boundary. Given:
- all callbacks are same-origin
fetchcalls with JSON bodies; and - you’re using strict CORS and Hono’s
secureHeaders/ CSP;
this is not immediately exploitable as a classic CSRF attacker from another origin. However, from a defense‑in‑depth point of view:
- backend should also enforce that the callback is tied to a previously issued
state, not just trust the SPA; - otherwise, if the frontend or origin constraints are weakened in future changes, this becomes a more serious gap.
Suggested change:
- When issuing the authorize URL, store
stateon the server side as well. Options:- Put it in a
httpOnlycookie bound to the provider (e.g.oauth_state_<provider>). - Or store in D1/KV keyed by some session identifier.
- Put it in a
- In
POST /api/oauth/callback/:provider:- Compare
body.state(or a header) with the server‑side stored state; - Reject if not equal or missing;
- Invalidate the stored state after one successful use (one‑time token).
- Compare
This keeps the current SPA behavior, but closes the loop at the backend boundary.
2. Whitelist semantics: OAUTH_ALLOWED_USERS === JWT_SECRET disables whitelist
File:
backend/src/features/auth/authService.ts
Current logic:
const allowedUsersStr = this.env.OAUTH_ALLOWED_USERS || '';
const allowedIdentities = allowedUsersStr.split(',')
.map(e => e.trim().toLowerCase())
.filter(Boolean);
const userEmail = (userInfo.email || '').toLowerCase();
const userName = (userInfo.username || '').toLowerCase();
// only enforce whitelist when configured AND OAUTH_ALLOWED_USERS != JWT_SECRET
if (allowedIdentities.length > 0 && this.env.OAUTH_ALLOWED_USERS !== this.env.JWT_SECRET) {
let isAllowed = false;
if (whitelistFields.includes('email') && userEmail && allowedIdentities.includes(userEmail)) {
isAllowed = true;
}
if (whitelistFields.includes('username') && userName && allowedIdentities.includes(userName)) {
isAllowed = true;
}
if (!isAllowed) {
throw new AppError(`Unauthorized user. Email: ${userEmail}, Username: ${userName}`, 403);
}
}
This means:
- if
OAUTH_ALLOWED_USERSis non‑empty and not equal toJWT_SECRET, whitelist is enforced; - if
OAUTH_ALLOWED_USERSis accidentally set equal toJWT_SECRET, whitelist silently turns off.
This is clever for dev/“allow all” setups, but it’s also very easy to misconfigure in production:
- a copy‑paste or env‑templating mistake that sets
OAUTH_ALLOWED_USERSto the same value asJWT_SECRETwill disable all whitelist checks with no warning.
Suggested change:
- Treat whitelist configuration as explicit:
- If
OAUTH_ALLOWED_USERSis non‑empty, always enforce it. - If you need a dev “allow all” mode, introduce a clear flag (e.g.
OAUTH_ALLOW_ALL=true) and document that it must never be used in production.
- If
- Optionally log a warning (or even refuse to start) if
OAUTH_ALLOWED_USERS === JWT_SECRET, since that almost certainly indicates a mistaken configuration.
3. Encryption key fallback to JWT_SECRET (DB secrets)
Files:
backend/src/shared/utils/crypto.tsbackend/src/shared/db/db.tsbackend/src/features/vault/vaultService.ts
VaultService currently derives its encryption key as:
constructor(env: EnvBindings, repository: VaultRepository) {
this.env = env;
this.repository = repository;
this.encryptionKey = env.ENCRYPTION_KEY || env.JWT_SECRET;
}
encryptField / decryptField then use this key with AES‑GCM:
export async function encryptField(data: any, key: string) {
const encrypted = await encryptData(data, key);
return JSON.stringify(encrypted);
}
Where “fast mode” encryptData does:
async function getFastKey(secret: string): Promise<CryptoKey> {
const keyBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(secret));
return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
So:
- if
ENCRYPTION_KEYis missing, all TOTP secrets in D1 are protected with a key derived fromJWT_SECRET; - a compromise of
JWT_SECRETcan then be used both to:- forge JWTs; and
- decrypt all stored TOTP secrets.
For a TOTP vault, this is a fairly severe coupling between two security domains.
Suggested change:
- In production mode, require a separate, high‑entropy
ENCRYPTION_KEY:- If
ENCRYPTION_KEYis missing, refuse to start with a clear error. - Keep
JWT_SECRETandENCRYPTION_KEYlogically independent.
- If
- Optionally:
- For new deployments, always use the “salt + PBKDF2 + AES‑GCM” style used for backup files, and reserve the “fast/legacy” path only for migration.
Even if the current fast mode is acceptable for D1 (since you still need Cloudflare secrets to read it), forcing a dedicated ENCRYPTION_KEY makes the threat model much cleaner.
4. Frontend security: notes (no obvious XSS, but high impact)
Files (sampling):
frontend/src/app/router.jsfrontend/src/features/auth/views/login.vuefrontend/src/features/auth/views/oauthCallback.vuefrontend/src/features/vault/views/home.vuefrontend/src/features/vault/components/vaultList.vuefrontend/src/features/vault/components/addVaultManual.vuefrontend/src/shared/utils/request.jsfrontend/src/shared/utils/crypto.jsfrontend/src/shared/utils/totp.jsfrontend/src/features/auth/store/userStore.jsfrontend/src/features/vault/store/vaultStore.js
Observations:
- Most user‑controlled data is rendered via normal
{{ }}binding, notv-html. - The only
v-htmlI found is on the login page for provider icons:<span v-html="provider.icon" style="display: flex;"></span>Butprovider.iconcomes from/api/oauth/providers, and those icons are hardcoded SVG strings in the backend provider classes. As long as these remain static and not env/user‑driven, this is safe. - All API calls go through a
request()helper that:- adds
X-CSRF-Tokenfromcsrf_tokencookie; - uses
credentials: 'include'; - handles 401 by clearing user info and redirecting to
/login.
- adds
- The vault’s offline cache in
localStorageis encrypted client‑side with PBKDF2+AES‑GCM keyed by thedeviceKeypersisted in IndexedDB. This is a nice extra layer.
Given the nature of this app (holding all TOTP seeds), any future introduction of XSS (e.g. via new v-html, unsafe HTML rendering, or third‑party script) would be catastrophic. I didn’t see a concrete XSS vector in the current code, but it’s worth documenting a strong “no dynamic HTML / no untrusted script” policy explicitly in the README.
Summary
From a security‑review perspective, the main things I’d recommend addressing are:
- Add server‑side validation of OAuth
state(in addition to the existing frontend check). - Remove or at least strongly discourage the “
OAUTH_ALLOWED_USERS === JWT_SECRETdisables whitelist” pattern; make whitelist semantics explicit. - Require a dedicated
ENCRYPTION_KEYand avoid silently falling back toJWT_SECRETfor vault encryption, at least in production. - Document the threat model more explicitly (Cloudflare account & Worker env are a single point of failure; any XSS = full vault compromise), and keep the frontend strict with regard to
v-htmland third‑party scripts.
Overall the codebase looks thoughtfully structured (Hono, strict CSP, CSRF middleware, offline vault encryption, Telegram HMAC verification etc.), which is great for this kind of application. The points above are mostly about tightening a few “sharp edges” and making misconfiguration harder.