Fix security audit findings: PayU secret leak, dead auth gate
All checks were successful
build-and-release / build (push) Successful in 1m2s
All checks were successful
build-and-release / build (push) Successful in 1m2s
- Remove fake client-side admin-session cookie gate (the real server-verified /api/auth/session check already handles auth redirects; the cookie was JS-forgeable and added no security) - Stop displaying full PayU merchant salt/client secret in the admin UI (backend now masks to last 4 chars); drop the plaintext-reveal toggle since there's nothing left to reveal - Bump patchable dependency vulnerabilities via npm audit fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fbf343263d
commit
d89c8bb3c1
5 changed files with 1272 additions and 973 deletions
2136
package-lock.json
generated
2136
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,6 @@ import {
|
||||||
import { Bell, Moon, Search, Settings, Sun, User } from "lucide-solid";
|
import { Bell, Moon, Search, Settings, Sun, User } from "lucide-solid";
|
||||||
import AdminSidebar from "./AdminSidebar";
|
import AdminSidebar from "./AdminSidebar";
|
||||||
import { isExternalIdentity } from "~/lib/admin-auth";
|
import { isExternalIdentity } from "~/lib/admin-auth";
|
||||||
import { clearAdminSession, hasAdminSession, setAdminSession } from "~/lib/admin-session";
|
|
||||||
import { normalizeAllowedModules } from "~/lib/admin/module-access";
|
import { normalizeAllowedModules } from "~/lib/admin/module-access";
|
||||||
import { sessionExpired, clearSessionExpired, installSessionExpiryWatcher } from "~/lib/session-expired";
|
import { sessionExpired, clearSessionExpired, installSessionExpiryWatcher } from "~/lib/session-expired";
|
||||||
import { installSessionRefreshLoop } from "~/lib/session-refresh";
|
import { installSessionRefreshLoop } from "~/lib/session-refresh";
|
||||||
|
|
@ -435,7 +434,6 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
||||||
sessionStorage.removeItem("nxtgauge_admin_access_token");
|
sessionStorage.removeItem("nxtgauge_admin_access_token");
|
||||||
sessionStorage.removeItem("nxtgauge_admin_preview");
|
sessionStorage.removeItem("nxtgauge_admin_preview");
|
||||||
}
|
}
|
||||||
clearAdminSession();
|
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -537,12 +535,8 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
||||||
onCleanup(() => clearInterval(interval));
|
onCleanup(() => clearInterval(interval));
|
||||||
|
|
||||||
const verify = async () => {
|
const verify = async () => {
|
||||||
if (!hasAdminSession()) {
|
// The server-verified session check below (/api/auth/session) is the
|
||||||
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
|
// real security boundary. It redirects to /login on any failure.
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const accessToken =
|
const accessToken =
|
||||||
typeof sessionStorage !== "undefined"
|
typeof sessionStorage !== "undefined"
|
||||||
|
|
@ -598,8 +592,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
||||||
|
|
||||||
setCheckedSession(true);
|
setCheckedSession(true);
|
||||||
} catch {
|
} catch {
|
||||||
clearAdminSession();
|
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
|
||||||
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
|
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
const SESSION_COOKIE = 'nxtgauge_admin_session';
|
|
||||||
const SESSION_VALUE = 'internal_management';
|
|
||||||
const SESSION_TTL_SECONDS = 60 * 60 * 12;
|
|
||||||
|
|
||||||
export function hasAdminSession(): boolean {
|
|
||||||
if (typeof document === 'undefined') return false;
|
|
||||||
// Check cookie exists
|
|
||||||
const hasCookie = document.cookie.split(';').some((entry) => entry.trim() === `${SESSION_COOKIE}=${SESSION_VALUE}`);
|
|
||||||
// Also check if sessionStorage has a valid token as fallback
|
|
||||||
const hasToken = (() => {
|
|
||||||
try {
|
|
||||||
const token = sessionStorage.getItem('nxtgauge_admin_access_token');
|
|
||||||
return Boolean(token && token.trim().length > 0);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return hasCookie || hasToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setAdminSession(): void {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
document.cookie = `${SESSION_COOKIE}=${SESSION_VALUE}; Path=/; Max-Age=${SESSION_TTL_SECONDS}; SameSite=Lax`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAdminSession(): void {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
document.cookie = `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
||||||
}
|
|
||||||
|
|
@ -47,6 +47,16 @@ function authHeaders() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server only ever sends masked secrets (e.g. "••••••••3f2a"); it never
|
||||||
|
// sends the real PayU merchant salt or OAuth client secret to the browser.
|
||||||
|
// A value containing the mask character means "unchanged" — if the admin
|
||||||
|
// doesn't edit the field, we must not send the placeholder back as if it
|
||||||
|
// were a real new secret.
|
||||||
|
const MASK_CHAR = '•';
|
||||||
|
function isMaskedPlaceholder(value: string): boolean {
|
||||||
|
return value.includes(MASK_CHAR);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePayload(payload: any): PaymentGatewayConfig {
|
function normalizePayload(payload: any): PaymentGatewayConfig {
|
||||||
const src = payload?.config || payload?.data || payload || {};
|
const src = payload?.config || payload?.data || payload || {};
|
||||||
return {
|
return {
|
||||||
|
|
@ -69,8 +79,6 @@ export default function PaymentGatewayManagementPage() {
|
||||||
const [saving, setSaving] = createSignal(false);
|
const [saving, setSaving] = createSignal(false);
|
||||||
const [error, setError] = createSignal('');
|
const [error, setError] = createSignal('');
|
||||||
const [success, setSuccess] = createSignal('');
|
const [success, setSuccess] = createSignal('');
|
||||||
const [showSecret, setShowSecret] = createSignal(false);
|
|
||||||
const [showClientSecret, setShowClientSecret] = createSignal(false);
|
|
||||||
const [cfg, setCfg] = createSignal<PaymentGatewayConfig>(DEFAULT_CONFIG);
|
const [cfg, setCfg] = createSignal<PaymentGatewayConfig>(DEFAULT_CONFIG);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
|
@ -191,18 +199,16 @@ export default function PaymentGatewayManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls}>Merchant Salt</label>
|
<label class={labelCls}>Merchant Salt</label>
|
||||||
<div class="flex gap-2">
|
<input
|
||||||
<input
|
type="password"
|
||||||
type={showSecret() ? 'text' : 'password'}
|
class={inputCls}
|
||||||
class={inputCls}
|
value={cfg().secretKey}
|
||||||
value={cfg().secretKey}
|
onInput={(e) => setField('secretKey', e.currentTarget.value)}
|
||||||
onInput={(e) => setField('secretKey', e.currentTarget.value)}
|
placeholder="PayU merchant salt"
|
||||||
placeholder="PayU merchant salt"
|
/>
|
||||||
/>
|
<Show when={isMaskedPlaceholder(cfg().secretKey)}>
|
||||||
<button type="button" class="rounded-lg border border-gray-200 px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" onClick={() => setShowSecret((v) => !v)}>
|
<p class="mt-1 text-xs text-gray-500">Masked — the real value is never sent to the browser. Type a new salt to replace it, or leave as-is to keep the current one.</p>
|
||||||
{showSecret() ? 'Hide' : 'Show'}
|
</Show>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls}>Client ID (optional)</label>
|
<label class={labelCls}>Client ID (optional)</label>
|
||||||
|
|
@ -210,18 +216,16 @@ export default function PaymentGatewayManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls}>Client Secret (optional)</label>
|
<label class={labelCls}>Client Secret (optional)</label>
|
||||||
<div class="flex gap-2">
|
<input
|
||||||
<input
|
type="password"
|
||||||
type={showClientSecret() ? 'text' : 'password'}
|
class={inputCls}
|
||||||
class={inputCls}
|
value={cfg().clientSecret}
|
||||||
value={cfg().clientSecret}
|
onInput={(e) => setField('clientSecret', e.currentTarget.value)}
|
||||||
onInput={(e) => setField('clientSecret', e.currentTarget.value)}
|
placeholder="PayU OAuth client secret"
|
||||||
placeholder="PayU OAuth client secret"
|
/>
|
||||||
/>
|
<Show when={isMaskedPlaceholder(cfg().clientSecret)}>
|
||||||
<button type="button" class="rounded-lg border border-gray-200 px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" onClick={() => setShowClientSecret((v) => !v)}>
|
<p class="mt-1 text-xs text-gray-500">Masked — the real value is never sent to the browser. Type a new secret to replace it, or leave as-is to keep the current one.</p>
|
||||||
{showClientSecret() ? 'Hide' : 'Show'}
|
</Show>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls}>Callback URL (surl/furl)</label>
|
<label class={labelCls}>Callback URL (surl/furl)</label>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useNavigate } from '@solidjs/router';
|
import { useNavigate } from '@solidjs/router';
|
||||||
import { Show, createMemo, createSignal, onMount } from 'solid-js';
|
import { Show, createMemo, createSignal } from 'solid-js';
|
||||||
import { isExternalIdentity, pickManagementLoginError } from '~/lib/admin-auth';
|
import { isExternalIdentity, pickManagementLoginError } from '~/lib/admin-auth';
|
||||||
import { hasAdminSession, setAdminSession } from '~/lib/admin-session';
|
|
||||||
|
|
||||||
type AuthMode = 'login' | 'reset';
|
type AuthMode = 'login' | 'reset';
|
||||||
type ResetStep = 'request' | 'verify';
|
type ResetStep = 'request' | 'verify';
|
||||||
|
|
@ -44,10 +43,12 @@ export default function LoginPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => { if (hasAdminSession()) navigate('/admin', { replace: true }); });
|
// Note: there is no client-side "already logged in" pre-redirect here.
|
||||||
|
// AdminShell performs the real server-verified session check
|
||||||
|
// (/api/auth/session) on every /admin/* page load and redirects to /login
|
||||||
|
// itself if unauthenticated, so this page doesn't need to duplicate that
|
||||||
|
// logic with a fake client-side session gate.
|
||||||
const completeAdminLogin = () => {
|
const completeAdminLogin = () => {
|
||||||
setAdminSession();
|
|
||||||
const from = new URLSearchParams(window.location.search).get('from');
|
const from = new URLSearchParams(window.location.search).get('from');
|
||||||
let destination = '/admin';
|
let destination = '/admin';
|
||||||
if (from) {
|
if (from) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue