diff --git a/src/router.ts b/src/router.ts index e6968fb..37ebfb9 100644 --- a/src/router.ts +++ b/src/router.ts @@ -121,6 +121,14 @@ function isSameOriginWriteRequest(request: Request): boolean { return false; } +function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret) return 'missing'; + if (secret === DEFAULT_DEV_SECRET) return 'default'; + if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; + return null; +} + function getNwIconSvg(): string { return `NW`; } @@ -222,8 +230,11 @@ export async function handleRequest(request: Request, env: Env): Promise(null); const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000); const [setupRegistered, setSetupRegistered] = useState(true); + const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(null); const [loginValues, setLoginValues] = useState({ email: '', password: '' }); const [registerValues, setRegisterValues] = useState({ @@ -153,6 +157,18 @@ export default function App() { if (!mounted) return; setSetupRegistered(setup.registered); setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000)); + const jwtUnsafeReason = config.jwtUnsafeReason || null; + if (jwtUnsafeReason) { + setJwtWarning({ + reason: jwtUnsafeReason, + minLength: Number(config.jwtSecretMinLength || 32), + }); + setSession(null); + setProfile(null); + setPhase('login'); + return; + } + setJwtWarning(null); const loaded = loadSession(); if (!loaded) { @@ -821,6 +837,10 @@ export default function App() { if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); }, [phase, location, isPublicSendRoute, navigate]); + if (jwtWarning) { + return ; + } + if (publicSendMatch) { return ( <> diff --git a/webapp/src/components/JwtWarningPage.tsx b/webapp/src/components/JwtWarningPage.tsx new file mode 100644 index 0000000..280b834 --- /dev/null +++ b/webapp/src/components/JwtWarningPage.tsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from 'preact/hooks'; +import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact'; +import StandalonePageFrame from '@/components/StandalonePageFrame'; +import { t } from '@/lib/i18n'; + +interface JwtWarningPageProps { + reason: 'missing' | 'default' | 'too_short'; + minLength: number; +} + +export default function JwtWarningPage(props: JwtWarningPageProps) { + const [seed, setSeed] = useState(0); + const [copyHint, setCopyHint] = useState(''); + + const generatedSecret = useMemo(() => generateJwtSecret(32), [seed]); + + const title = + props.reason === 'missing' + ? t('txt_jwt_title_missing') + : props.reason === 'default' + ? t('txt_jwt_title_default') + : t('txt_jwt_title_too_short'); + + const isMissing = props.reason === 'missing'; + const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace'); + const fixStep1 = isMissing ? t('txt_jwt_add_step_1') : t('txt_jwt_replace_step_1', { min: props.minLength }); + const fixStep2 = isMissing ? t('txt_jwt_add_step_2') : t('txt_jwt_replace_step_2'); + const fixStep3 = isMissing ? t('txt_jwt_add_step_3') : t('txt_jwt_replace_step_3'); + + return ( +
+ +
+ + {t('txt_jwt_warning_subtitle')} +
+ +
+
{fixTitle}
+
    +
  1. {fixStep1}
  2. +
  3. {fixStep2}
  4. +
  5. {fixStep3}
  6. +
+ +
+
{t('txt_random_secret_generator')}
+ +
+ + + {copyHint && {copyHint}} +
+
+
+
+
+ ); +} + +function generateJwtSecret(length: number): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const bytes = crypto.getRandomValues(new Uint8Array(length)); + let out = ''; + for (let i = 0; i < length; i += 1) { + out += chars[bytes[i] % chars.length]; + } + return out; +} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index a203617..d8d1288 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -171,6 +171,29 @@ const messages: Record> = { txt_linux_desktop: "Linux Desktop", txt_loading: "Loading...", txt_loading_nodewarden: "Loading NodeWarden...", + txt_jwt_warning_title: "Server Security Warning", + txt_jwt_warning_subtitle: "JWT secret is not configured safely.", + txt_jwt_title_missing: "JWT_SECRET is missing", + txt_jwt_title_too_short: "JWT_SECRET is too short", + txt_jwt_title_default: "JWT_SECRET is using the default value", + txt_jwt_reason_missing: "JWT secret is missing.", + txt_jwt_reason_default: "JWT secret is still the default/sample value.", + txt_jwt_reason_too_short: "JWT secret is too short. Minimum length is {min}.", + txt_jwt_how_to_fix_add: "How to add JWT_SECRET", + txt_jwt_how_to_fix_replace: "How to replace JWT_SECRET", + txt_jwt_add_step_1: "Use the 32-character generator below and copy a new key.", + txt_jwt_add_step_2: "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, add JWT_SECRET.", + txt_jwt_add_step_3: "Save and wait for redeploy, then refresh this page.", + txt_jwt_replace_step_1: "Use the 32-character generator below and create a stronger key (minimum {min} characters).", + txt_jwt_replace_step_2: "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, replace JWT_SECRET.", + txt_jwt_replace_step_3: "Save and wait for redeploy, then refresh this page.", + txt_how_to_fix: "How to fix", + txt_jwt_fix_step_1: "Open your deployment environment variables.", + txt_jwt_fix_step_2: "If your current key is not random enough, use the 32-character generator below.", + txt_jwt_fix_step_3: "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, update JWT_SECRET.", + txt_jwt_fix_step_4: "Save and wait for redeploy, then refresh this page to verify.", + txt_random_secret_generator: "Random Secret Generator", + txt_copied: "Copied", txt_log_in: "Log In", txt_log_out: "Log Out", txt_login: "Login", @@ -672,6 +695,29 @@ const zhCNOverrides: Record = { txt_verify: '验证', txt_web: '网页', txt_windows_desktop: 'Windows 桌面端', + txt_jwt_warning_title: 'JWT_SECRET 配置警告', + txt_jwt_warning_subtitle: 'JWT 密钥当前不安全,请先修复后再继续。', + txt_jwt_title_missing: '未检测到 JWT_SECRET', + txt_jwt_title_too_short: 'JWT_SECRET 长度过短', + txt_jwt_title_default: 'JWT_SECRET使用默认值', + txt_jwt_reason_missing: '未检测到 JWT_SECRET。', + txt_jwt_reason_default: 'JWT_SECRET 仍在使用默认示例值。', + txt_jwt_reason_too_short: 'JWT_SECRET 长度过短,至少需要 {min} 位。', + txt_jwt_how_to_fix_add: '处理步骤(添加 JWT_SECRET)', + txt_jwt_how_to_fix_replace: '处理步骤(更换 JWT_SECRET)', + txt_jwt_add_step_1: '使用下方 32 位随机生成器,复制一个新密钥。', + txt_jwt_add_step_2: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,新增 JWT_SECRET。', + txt_jwt_add_step_3: '保存并等待重新部署完成,然后刷新本页确认。', + txt_jwt_replace_step_1: '使用下方 32 位随机生成器,生成更强的密钥(至少 {min} 位)。', + txt_jwt_replace_step_2: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,替换 JWT_SECRET。', + txt_jwt_replace_step_3: '保存并等待重新部署完成,然后刷新本页确认。', + txt_how_to_fix: '处理步骤(添加 / 更换)', + txt_jwt_fix_step_1: '你可以继续下一步,不影响使用。', + txt_jwt_fix_step_2: '如果当前密钥不是强随机值,建议使用下方 32 位生成器。', + txt_jwt_fix_step_3: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,更新 JWT_SECRET。', + txt_jwt_fix_step_4: '保存并等待重新部署完成,然后刷新本页确认。', + txt_random_secret_generator: '随机密钥生成器', + txt_copied: '已复制', }; messages['zh-CN'] = { ...messages.en, ...zhCNOverrides }; diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 9f1ec84..82c69cd 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -237,6 +237,8 @@ export interface SetupStatusResponse { export interface WebConfigResponse { defaultKdfIterations?: number; + jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null; + jwtSecretMinLength?: number; } export interface TokenSuccess { diff --git a/webapp/src/styles.css b/webapp/src/styles.css index d092868..3a6145e 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -107,6 +107,55 @@ body, text-align: left; } +.jwt-warning-head { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 10px; + color: #b45309; + text-align: center; +} + +.jwt-warning-box { + border: 1px solid #f1d8a5; + border-radius: 12px; + background: #fffaf0; + padding: 12px 14px; +} + +.jwt-warning-label { + font-size: 13px; + font-weight: 700; + color: #92400e; + margin-bottom: 6px; +} + +.jwt-warning-list { + margin: 0; + padding-left: 18px; + color: #334155; + line-height: 1.55; +} + +.jwt-generator { + margin-top: 14px; +} + +.jwt-generator-actions { + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.jwt-copy-hint { + color: #15803d; + font-size: 13px; + font-weight: 700; +} + .standalone-footer { width: 100%; text-align: center; @@ -330,7 +379,7 @@ input[type='file'].input::file-selector-button:hover { } .app-shell { - height: calc(80vh - 40px); + height: calc(100vh - 40px); max-width: 1600px; margin: 0 auto; background: #f5f7fb;