Compare commits

...

4 Commits

Author SHA1 Message Date
codex-bot f88b300f0b Merge remote-tracking branch 'origin/codex/wiki-auth-hardening' into codex/linked-guidance 2026-10-29 16:52:05 +08:00
codex-bot a1984e615a Verify public knowledge reads and protected administrator writes
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m22s
2026-10-29 16:49:27 +08:00
codex-bot b157504ca6 Keep account session revocation available when rate limited
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m15s
2026-10-29 16:46:50 +08:00
codex-bot b131e1b11d Fail closed without admin configuration and document SMTP operations
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m22s
2026-10-29 16:41:50 +08:00
6 changed files with 65 additions and 5 deletions
+49
View File
@@ -0,0 +1,49 @@
# Email accounts
`/account` offers registration, password login, email verification/resend, and logout.
`/api/account/session` returns the current account; other actions use same-origin POST.
Passwords require at least 12 characters and at most 256 UTF-8 bytes. Only verified
accounts can log in. Verification links expire after 30 minutes and are consumed once.
Session tokens expire after seven days; logout deletes the current token immediately.
Only token SHA-256 digests are stored. Passwords use individually salted scrypt.
## Runtime configuration
The existing production deployment reads `/etc/prompt.env` on ces-control. Set its
ownership to root and mode to 0600. The operator should update SMTP_PASSWORD there
through a secure remote session, without putting it in command arguments, repository,
CI output, release.env, or chat. Existing compose deployment reads this file without
copying credentials into the release directory. Do not run `docker compose config`
without `--quiet` against the production environment because interpolation shows secrets.
Docker administrators can inspect container environment; access to Docker is privileged.
Set ACCOUNT_PUBLIC_ORIGIN to the exact browser origin and SMTP_HOST=smtpdm.aliyun.com,
SMTP_PORT=465, SMTP_USERNAME and SMTP_FROM=noreply@notice.entroia.com. TLS certificate
validation stays enabled. Missing SMTP configuration returns a generic service failure;
resend with the registration password can recover an account whose initial delivery failed.
Keep AUTH_CONFIG_FILE configured for administrator access. Missing administrator
configuration fails closed. AUTH_DISABLED_FOR_TESTS is false by default and is enabled
only by the disposable legacy integration stack; never enable it in production.
Email account cookies never satisfy administrator authorization. With the current HTTP
acceptance origin use AUTH_COOKIE_SECURE=false; use true after enabling HTTPS.
Limits persist in PostgreSQL: 10 logins and 3 registration/resend attempts per email per
15 minutes, plus 300 requests per 15 minutes globally. ACCOUNT_TRUST_PROXY defaults
false. Enable it only when a trusted ingress overwrites x-forwarded-for; this changes
the global cap to 60 per trusted client address. Do not enable it on the direct public port.
Limits count failures and successes. Registration/resend always give the same success
response for existing/unknown emails, and login uses a dummy KDF for unknown accounts.
The rate limit itself can reveal recent activity for a known email; it does not reveal
whether an account exists. Synchronous SMTP latency can still expose a timing side channel;
a durable asynchronous mail queue would be needed to fully decouple delivery timing.
Logout remains available when login/mail limits are exhausted. No password recovery or
account deletion is included.
## Verification
ces-control runs crypto/origin tests, PostgreSQL verification-token replay races,
expired/revoked sessions, concurrent limit consumption and unverified login rejection.
Production-auth exercises verify that account cookies cannot authorize legacy writes.
Actual SMTP delivery and visual acceptance at http://8.211.154.134:18131/account require
operator runtime configuration and the normal manually approved deployment.
@@ -14,10 +14,11 @@ export async function POST(request: Request,context: Context) {
const {action} = await context.params;
if (!['register','resend','verify','login','logout'].includes(action)) return json({error:'不存在'},404);
try {
// Revocation remains available even when login or mail limits are exhausted.
if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; }
// Use a global cap by default: forwarding headers are untrusted unless the deployment explicitly enables a trusted ingress.
const ip = process.env.ACCOUNT_TRUST_PROXY === 'true' ? request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown' : 'global';
if (!await rateLimit('request:'+ip,process.env.ACCOUNT_TRUST_PROXY === 'true' ? 60 : 300)) return json({error:'请求过于频繁,请稍后再试'},429);
if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; }
if (Number(request.headers.get('content-length') || 0)>4096) return json({error:'请求过大'},413);
const text=await request.text(); if(text.length>4096)return json({error:'请求过大'},413);
let body;try{body=JSON.parse(text);}catch{return json({error:'请求格式错误'},400);}
+4 -1
View File
@@ -9,7 +9,10 @@ export function proxy(request: NextRequest) {
if (path === '/brand-mark.svg' || path === '/login' || path === '/api/auth/login' || path === '/api/auth/logout' || path === '/api/health' || path === '/api/ready' || path.startsWith('/_next/static/')) return NextResponse.next({headers:responseHeaders});
let config;
try { config = authConfig(); } catch { return NextResponse.json({error:'登录服务配置不可用'}, {status:503,headers:responseHeaders}); }
if (!config) return NextResponse.next();
if (!config) {
if (process.env.AUTH_DISABLED_FOR_TESTS === 'true' && process.env.AUTH_CONFIG_FILE === '') return NextResponse.next();
return NextResponse.json({error:'管理员登录服务尚未配置'}, {status:503,headers:responseHeaders});
}
const bearer = request.headers.get('authorization');
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : request.cookies.get(SESSION_COOKIE)?.value;
if (validSession(token,config)) {
+7 -3
View File
@@ -34,14 +34,18 @@ test('proxy protects APIs, data routes, static content and Studio; fails closed'
const temp=mkdtempSync(join(tmpdir(),'prompt-auth-')); const old=process.env.AUTH_CONFIG_FILE;
try{
const config=newAuthConfig(); process.env.AUTH_CONFIG_FILE=join(temp,'config.json'); writeFileSync(process.env.AUTH_CONFIG_FILE,JSON.stringify(config));
for(const path of ['/','/library/import','/skills/example.png','/_next/data/build/index.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
for(const path of ['/library/import','/skills/example.png','/_next/data/build/index.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
for(const path of ['/api/upload','/studio/api/export','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
for(const path of ['/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
for(const path of ['/','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
const headers={cookie:'prompt_session='+signSession(config),host:'wiki.test'};
assert.equal(proxy(new NextRequest('http://wiki.test/api/feed',{headers})).status,200);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://evil.test'}})).status,403);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://wiki.test'}})).status,200);
writeFileSync(process.env.AUTH_CONFIG_FILE,'{}'); assert.throws(()=>authConfig()); assert.equal(proxy(new NextRequest('http://wiki.test/')).status,503);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{cookie:'wiki_account='+ 'a'.repeat(64)}})).status,401);
assert.equal(proxy(new NextRequest('http://wiki.test/guide/welcome',{method:'POST'})).status,401);
writeFileSync(process.env.AUTH_CONFIG_FILE,'{}'); assert.throws(()=>authConfig()); assert.equal(proxy(new NextRequest('http://wiki.test/api/posts')).status,503);
delete process.env.AUTH_CONFIG_FILE;
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts')).status,503,'Missing admin configuration fails closed');
}finally{if(old===undefined)delete process.env.AUTH_CONFIG_FILE;else process.env.AUTH_CONFIG_FILE=old;rmSync(temp,{recursive:true,force:true});}
});
+1
View File
@@ -12,6 +12,7 @@ x-waterfall-wiki-environment: &waterfall-wiki-environment
CHROMIUM_PATH: /usr/bin/chromium
RENDER_ORIGIN: http://waterfall-wiki:3000
AUTH_CONFIG_FILE: ${AUTH_CONFIG_FILE:-}
AUTH_DISABLED_FOR_TESTS: ${AUTH_DISABLED_FOR_TESTS:-false}
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-true}
ACCOUNT_PUBLIC_ORIGIN: ${ACCOUNT_PUBLIC_ORIGIN:-http://8.211.154.134:18131}
ACCOUNT_TRUST_PROXY: ${ACCOUNT_TRUST_PROXY:-false}
+2
View File
@@ -11,6 +11,8 @@ cleanup(){ code=$?; if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall
trap cleanup EXIT
# Random credentials exist only for this disposable stack; no host Node required.
docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$TEST_ENV"
# Explicitly isolate legacy unauthenticated integration coverage; production fails closed.
printf '\nAUTH_DISABLED_FOR_TESTS=true\n' >> "$TEST_ENV"
compose up -d --no-build --wait --wait-timeout 120 postgres objects
compose run --rm --no-deps migrate
compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki waterfall-wiki-worker