Fix security audit findings: PayU secret leak, dead auth gate
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:
Ashwin Kumar Sivakumar 2026-07-23 17:32:28 +05:30
parent fbf343263d
commit d89c8bb3c1
5 changed files with 1272 additions and 973 deletions

2136
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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,
}); });
} }

View file

@ -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`;
}

View file

@ -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>

View file

@ -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) {