mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
Harden 2FA disable and website icon privacy
This commit is contained in:
@@ -39,6 +39,9 @@
|
|||||||
},
|
},
|
||||||
"ATTACHMENTS_KV": {
|
"ATTACHMENTS_KV": {
|
||||||
"description": "Optional KV namespace fallback for attachment/send-file storage"
|
"description": "Optional KV namespace fallback for attachment/send-file storage"
|
||||||
|
},
|
||||||
|
"WEBSITE_ICONS_ENABLED": {
|
||||||
|
"description": "Optional: set to true to proxy website icons via third-party icon services. Defaults to disabled for vault-domain privacy."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1161,16 +1161,8 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
|||||||
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
|
|
||||||
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
|
||||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
let verified = false;
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
if (key && userVerificationToken) {
|
|
||||||
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
|
|
||||||
}
|
|
||||||
if (!verified) {
|
|
||||||
verified = await verifyUserSecret(auth, user, secret);
|
|
||||||
}
|
|
||||||
if (!verified) return errorResponse('User verification failed.', 400);
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||||
|
|||||||
+12
-2
@@ -46,6 +46,11 @@ export interface WebBootstrapResponse {
|
|||||||
jwtSecretMinLength: number;
|
jwtSecretMinLength: number;
|
||||||
registrationInviteRequired: boolean;
|
registrationInviteRequired: boolean;
|
||||||
webAuthnAllowedOrigins: string[];
|
webAuthnAllowedOrigins: string[];
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebsiteIconProxyEnabled(env: Env): boolean {
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(String(env.WEBSITE_ICONS_ENABLED || '').trim().toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
@@ -257,7 +262,11 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
|
if (!isWebsiteIconProxyEnabled(env)) {
|
||||||
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
@@ -325,6 +334,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
|||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
registrationInviteRequired: userCount > 0,
|
registrationInviteRequired: userCount > 0,
|
||||||
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||||
|
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +385,7 @@ export async function handlePublicRoute(
|
|||||||
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface Env {
|
|||||||
'globalSettings__yubico__clientId'?: string;
|
'globalSettings__yubico__clientId'?: string;
|
||||||
'globalSettings__yubico__key'?: string;
|
'globalSettings__yubico__key'?: string;
|
||||||
'globalSettings__yubico__validationUrls'?: string;
|
'globalSettings__yubico__validationUrls'?: string;
|
||||||
|
WEBSITE_ICONS_ENABLED?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'user';
|
export type UserRole = 'admin' | 'user';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/lib/website-icon-cache';
|
} from '@/lib/website-icon-cache';
|
||||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||||
|
import { areWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
@@ -22,7 +23,8 @@ interface WebsiteIconProps {
|
|||||||
|
|
||||||
export default function WebsiteIcon(props: WebsiteIconProps) {
|
export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
||||||
const src = host ? websiteIconUrl(host) : '';
|
const iconsEnabled = areWebsiteIconsEnabled();
|
||||||
|
const src = iconsEnabled && host ? websiteIconUrl(host) : '';
|
||||||
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||||
@@ -33,7 +35,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host) {
|
if (!host || !iconsEnabled) {
|
||||||
setShouldLoad(true);
|
setShouldLoad(true);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setImageUrl('');
|
setImageUrl('');
|
||||||
@@ -47,7 +49,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
setStatus(next);
|
setStatus(next);
|
||||||
setImageUrl(getWebsiteIconImageUrl(host));
|
setImageUrl(getWebsiteIconImageUrl(host));
|
||||||
});
|
});
|
||||||
}, [host]);
|
}, [host, iconsEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
||||||
@@ -81,10 +83,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||||
if (demoIconUrl) return;
|
if (demoIconUrl) return;
|
||||||
|
if (!iconsEnabled) return;
|
||||||
if (networkStatus !== 'online') return;
|
if (networkStatus !== 'online') return;
|
||||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||||
beginWebsiteIconLoad(host, src);
|
beginWebsiteIconLoad(host, src);
|
||||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
}, [demoIconUrl, host, iconsEnabled, networkStatus, src, shouldLoad, status]);
|
||||||
|
|
||||||
if (demoIconUrl) {
|
if (demoIconUrl) {
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +103,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!host || status === 'error') {
|
if (!host || !iconsEnabled || status === 'error') {
|
||||||
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
unlockOfflineVaultWithMasterKey,
|
unlockOfflineVaultWithMasterKey,
|
||||||
} from '@/lib/offline-auth';
|
} from '@/lib/offline-auth';
|
||||||
import { probeNodeWardenService } from '@/lib/network-status';
|
import { probeNodeWardenService } from '@/lib/network-status';
|
||||||
|
import { setWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||||
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||||
|
|
||||||
export interface PendingTotp {
|
export interface PendingTotp {
|
||||||
@@ -51,6 +52,7 @@ export type JwtUnsafeReason = 'missing' | 'too_short';
|
|||||||
export interface BootstrapAppResult {
|
export interface BootstrapAppResult {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
@@ -61,6 +63,7 @@ export interface BootstrapAppResult {
|
|||||||
export interface InitialAppBootstrapState {
|
export interface InitialAppBootstrapState {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
phase: AppPhase;
|
phase: AppPhase;
|
||||||
@@ -229,10 +232,11 @@ function readWindowBootstrap(): WebBootstrapResponse {
|
|||||||
return raw && typeof raw === 'object' ? raw : {};
|
return raw && typeof raw === 'object' ? raw : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'jwtWarning'> {
|
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'websiteIconsEnabled' | 'jwtWarning'> {
|
||||||
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
||||||
const registrationInviteRequired =
|
const registrationInviteRequired =
|
||||||
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
||||||
|
const websiteIconsEnabled = boot.websiteIconsEnabled === true;
|
||||||
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
||||||
const jwtWarning = jwtUnsafeReason
|
const jwtWarning = jwtUnsafeReason
|
||||||
? {
|
? {
|
||||||
@@ -244,6 +248,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -304,7 +309,8 @@ function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undef
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||||
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
const { defaultKdfIterations, registrationInviteRequired, websiteIconsEnabled, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
||||||
|
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
const hasInviteCode = !!readInviteCodeFromUrl();
|
const hasInviteCode = !!readInviteCodeFromUrl();
|
||||||
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
||||||
@@ -312,6 +318,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session,
|
session,
|
||||||
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
||||||
@@ -323,12 +330,15 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
||||||
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
||||||
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
||||||
|
const websiteIconsEnabled = normalizedBoot.websiteIconsEnabled;
|
||||||
|
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||||
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -341,6 +351,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -353,6 +364,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: cachedProfile,
|
profile: cachedProfile,
|
||||||
@@ -364,6 +376,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: null,
|
profile: null,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
registrationInviteRequired: true,
|
registrationInviteRequired: true,
|
||||||
|
websiteIconsEnabled: false,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
|
|||||||
@@ -790,6 +790,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
registrationInviteRequired: true,
|
registrationInviteRequired: true,
|
||||||
|
websiteIconsEnabled: false,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ export interface WebBootstrapResponse {
|
|||||||
jwtSecretMinLength?: number;
|
jwtSecretMinLength?: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
webAuthnAllowedOrigins?: string[];
|
webAuthnAllowedOrigins?: string[];
|
||||||
|
websiteIconsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YubiKeyOtpSettings {
|
export interface YubiKeyOtpSettings {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
let websiteIconsEnabled = false;
|
||||||
|
|
||||||
|
export function setWebsiteIconsEnabled(enabled: boolean): void {
|
||||||
|
websiteIconsEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areWebsiteIconsEnabled(): boolean {
|
||||||
|
return websiteIconsEnabled;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user