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 AdminSidebar from "./AdminSidebar";
|
||||
import { isExternalIdentity } from "~/lib/admin-auth";
|
||||
import { clearAdminSession, hasAdminSession, setAdminSession } from "~/lib/admin-session";
|
||||
import { normalizeAllowedModules } from "~/lib/admin/module-access";
|
||||
import { sessionExpired, clearSessionExpired, installSessionExpiryWatcher } from "~/lib/session-expired";
|
||||
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_preview");
|
||||
}
|
||||
clearAdminSession();
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
};
|
||||
|
|
@ -537,12 +535,8 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
onCleanup(() => clearInterval(interval));
|
||||
|
||||
const verify = async () => {
|
||||
if (!hasAdminSession()) {
|
||||
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The server-verified session check below (/api/auth/session) is the
|
||||
// real security boundary. It redirects to /login on any failure.
|
||||
try {
|
||||
const accessToken =
|
||||
typeof sessionStorage !== "undefined"
|
||||
|
|
@ -598,8 +592,7 @@ export default function AdminShell(props: { children: JSX.Element }) {
|
|||
|
||||
setCheckedSession(true);
|
||||
} catch {
|
||||
clearAdminSession();
|
||||
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
|
||||
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
|
||||
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 {
|
||||
const src = payload?.config || payload?.data || payload || {};
|
||||
return {
|
||||
|
|
@ -69,8 +79,6 @@ export default function PaymentGatewayManagementPage() {
|
|||
const [saving, setSaving] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
const [success, setSuccess] = createSignal('');
|
||||
const [showSecret, setShowSecret] = createSignal(false);
|
||||
const [showClientSecret, setShowClientSecret] = createSignal(false);
|
||||
const [cfg, setCfg] = createSignal<PaymentGatewayConfig>(DEFAULT_CONFIG);
|
||||
|
||||
const load = async () => {
|
||||
|
|
@ -191,18 +199,16 @@ export default function PaymentGatewayManagementPage() {
|
|||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Merchant Salt</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type={showSecret() ? 'text' : 'password'}
|
||||
class={inputCls}
|
||||
value={cfg().secretKey}
|
||||
onInput={(e) => setField('secretKey', e.currentTarget.value)}
|
||||
placeholder="PayU merchant salt"
|
||||
/>
|
||||
<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)}>
|
||||
{showSecret() ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
class={inputCls}
|
||||
value={cfg().secretKey}
|
||||
onInput={(e) => setField('secretKey', e.currentTarget.value)}
|
||||
placeholder="PayU merchant salt"
|
||||
/>
|
||||
<Show when={isMaskedPlaceholder(cfg().secretKey)}>
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Client ID (optional)</label>
|
||||
|
|
@ -210,18 +216,16 @@ export default function PaymentGatewayManagementPage() {
|
|||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Client Secret (optional)</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type={showClientSecret() ? 'text' : 'password'}
|
||||
class={inputCls}
|
||||
value={cfg().clientSecret}
|
||||
onInput={(e) => setField('clientSecret', e.currentTarget.value)}
|
||||
placeholder="PayU OAuth client secret"
|
||||
/>
|
||||
<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)}>
|
||||
{showClientSecret() ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
class={inputCls}
|
||||
value={cfg().clientSecret}
|
||||
onInput={(e) => setField('clientSecret', e.currentTarget.value)}
|
||||
placeholder="PayU OAuth client secret"
|
||||
/>
|
||||
<Show when={isMaskedPlaceholder(cfg().clientSecret)}>
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Callback URL (surl/furl)</label>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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 { hasAdminSession, setAdminSession } from '~/lib/admin-session';
|
||||
|
||||
type AuthMode = 'login' | 'reset';
|
||||
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 = () => {
|
||||
setAdminSession();
|
||||
const from = new URLSearchParams(window.location.search).get('from');
|
||||
let destination = '/admin';
|
||||
if (from) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue