mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add JWT secret safety checks and warning page for insecure configurations
This commit is contained in:
@@ -9,6 +9,7 @@ import VaultPage from '@/components/VaultPage';
|
||||
import SendsPage from '@/components/SendsPage';
|
||||
import PublicSendPage from '@/components/PublicSendPage';
|
||||
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
||||
import JwtWarningPage from '@/components/JwtWarningPage';
|
||||
import SettingsPage from '@/components/SettingsPage';
|
||||
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
|
||||
import AdminPage from '@/components/AdminPage';
|
||||
@@ -65,6 +66,8 @@ interface PendingTotp {
|
||||
masterKey: Uint8Array;
|
||||
}
|
||||
|
||||
type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
|
||||
const SEND_KEY_SALT = 'bitwarden-send';
|
||||
const SEND_KEY_PURPOSE = 'send';
|
||||
|
||||
@@ -87,6 +90,7 @@ export default function App() {
|
||||
const [profile, setProfile] = useState<Profile | null>(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 <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
||||
}
|
||||
|
||||
if (publicSendMatch) {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={title}>
|
||||
<div className="jwt-warning-head">
|
||||
<AlertTriangle size={20} />
|
||||
<strong>{t('txt_jwt_warning_subtitle')}</strong>
|
||||
</div>
|
||||
|
||||
<div className="jwt-warning-box">
|
||||
<div className="jwt-warning-label">{fixTitle}</div>
|
||||
<ol className="jwt-warning-list">
|
||||
<li>{fixStep1}</li>
|
||||
<li>{fixStep2}</li>
|
||||
<li>{fixStep3}</li>
|
||||
</ol>
|
||||
|
||||
<div className="jwt-generator">
|
||||
<div className="jwt-warning-label">{t('txt_random_secret_generator')}</div>
|
||||
<input className="input input-readonly" readOnly value={generatedSecret} />
|
||||
<div className="jwt-generator-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => setSeed((v) => v + 1)}>
|
||||
<RefreshCw size={15} className="btn-icon" />
|
||||
{t('txt_regenerate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(generatedSecret);
|
||||
setCopyHint(t('txt_copied'));
|
||||
window.setTimeout(() => setCopyHint(''), 1500);
|
||||
}}
|
||||
>
|
||||
<Copy size={15} className="btn-icon" />
|
||||
{t('txt_copy')}
|
||||
</button>
|
||||
{copyHint && <span className="jwt-copy-hint">{copyHint}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StandalonePageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -171,6 +171,29 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
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<string, string> = {
|
||||
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 };
|
||||
|
||||
@@ -237,6 +237,8 @@ export interface SetupStatusResponse {
|
||||
|
||||
export interface WebConfigResponse {
|
||||
defaultKdfIterations?: number;
|
||||
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
|
||||
jwtSecretMinLength?: number;
|
||||
}
|
||||
|
||||
export interface TokenSuccess {
|
||||
|
||||
+50
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user