From 05473955869901daee02d9edc4e4aece520d2a08 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 13:02:00 -0700 Subject: [PATCH 1/4] harden(auth): add password strength meter and tighten CSP Use estimator-based password validation so short-but-strong generated passwords can pass while common weak passwords are rejected, and remove wildcard HTTPS connect-src from the key-custody SPA CSP. Made-with: Cursor --- package-lock.json | 9 +++- package.json | 3 +- server.js | 12 ++++- src/App.css | 50 ++++++++++++++++++ src/components/ChangePasswordCard.js | 10 +++- src/components/ChangePasswordCard.test.js | 7 ++- src/components/PasswordStrengthMeter.js | 62 +++++++++++++++++++++++ src/lib/passwordPolicy.js | 58 ++++++++++++++++----- src/lib/passwordPolicy.test.js | 38 ++++++++++++++ src/pages/Register.js | 15 +++++- src/pages/Register.test.js | 18 ++++++- 11 files changed, 257 insertions(+), 25 deletions(-) create mode 100644 src/components/PasswordStrengthMeter.js create mode 100644 src/lib/passwordPolicy.test.js diff --git a/package-lock.json b/package-lock.json index d1810dc..55e9237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,8 @@ "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-router-dom": "^5.3.4", - "react-scripts": "5.0.1" + "react-scripts": "5.0.1", + "zxcvbn": "^4.4.2" }, "devDependencies": { "axios-mock-adapter": "^2.1.0" @@ -20847,6 +20848,12 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 6e17c30..ad1d7e2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-router-dom": "^5.3.4", - "react-scripts": "5.0.1" + "react-scripts": "5.0.1", + "zxcvbn": "^4.4.2" }, "scripts": { "start": "react-scripts start", diff --git a/server.js b/server.js index 2e1f79b..28c22b9 100644 --- a/server.js +++ b/server.js @@ -16,10 +16,18 @@ function uniqueSources(sources) { return [...new Set(sources.filter(Boolean))]; } +function explicitConnectSources(value) { + return splitCspSources(value).filter( + (source) => !['*', 'http:', 'https:', 'ws:', 'wss:'].includes(source) + ); +} + const connectSrc = uniqueSources([ "'self'", - 'https:', - ...splitCspSources(process.env.SYSNODE_CSP_CONNECT_SRC), + // Key-custody pages must not allow arbitrary HTTPS exfiltration. Keep + // production same-origin by default; deployments that truly need another + // endpoint can add exact origins via SYSNODE_CSP_CONNECT_SRC. + ...explicitConnectSources(process.env.SYSNODE_CSP_CONNECT_SRC), ]); // HSTS is owned in code so any deployer (behind nginx, Caddy, a managed load diff --git a/src/App.css b/src/App.css index 331eecc..6583537 100644 --- a/src/App.css +++ b/src/App.css @@ -1844,6 +1844,56 @@ color: var(--muted); } +.password-meter { + display: grid; + gap: 6px; + margin-top: 2px; + font-size: 0.82rem; + color: var(--muted); +} + +.password-meter__topline { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.password-meter__topline strong { + color: var(--text); +} + +.password-meter__track { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(20, 30, 43, 0.1); +} + +.password-meter__bar { + display: block; + height: 100%; + border-radius: inherit; + background: #c63024; + transition: width 140ms ease, background-color 140ms ease; +} + +.password-meter[data-score='1'] .password-meter__bar { + background: #d97826; +} + +.password-meter[data-score='2'] .password-meter__bar { + background: #caa21d; +} + +.password-meter[data-score='3'] .password-meter__bar, +.password-meter[data-score='4'] .password-meter__bar { + background: #238857; +} + +.password-meter__feedback { + margin: 0; +} + .auth-alert { background: rgba(229, 107, 85, 0.1); border: 1px solid rgba(229, 107, 85, 0.35); diff --git a/src/components/ChangePasswordCard.js b/src/components/ChangePasswordCard.js index 01d4f5f..a998912 100644 --- a/src/components/ChangePasswordCard.js +++ b/src/components/ChangePasswordCard.js @@ -1,6 +1,7 @@ import React, { useCallback, useState } from 'react'; import { authService as defaultAuthService } from '../lib/authService'; +import PasswordStrengthMeter from './PasswordStrengthMeter'; import { useAuth } from '../context/AuthContext'; import { useVault } from '../context/VaultContext'; import { @@ -109,6 +110,7 @@ export default function ChangePasswordCard({ const [localError, setLocalError] = useState(null); const [success, setSuccess] = useState(false); const [open, setOpen] = useState(defaultOpen); + const userEmail = user && user.email ? user.email : ''; const clearFeedback = useCallback(() => { setErrCode(null); @@ -130,7 +132,7 @@ export default function ChangePasswordCard({ setLocalError('Enter your current password.'); return; } - const passwordError = validateVaultPassword(newPassword); + const passwordError = validateVaultPassword(newPassword, [user.email]); if (passwordError) { setLocalError(passwordError.message); return; @@ -329,9 +331,15 @@ export default function ChangePasswordCard({ value={newPassword} onChange={(e) => setNewPassword(e.target.value)} minLength={MIN_VAULT_PASSWORD_LENGTH} + aria-describedby="cp-new-strength" required /> {VAULT_PASSWORD_HINT} +
diff --git a/src/components/ChangePasswordCard.test.js b/src/components/ChangePasswordCard.test.js index ff2e629..10fdcc6 100644 --- a/src/components/ChangePasswordCard.test.js +++ b/src/components/ChangePasswordCard.test.js @@ -193,18 +193,17 @@ describe('ChangePasswordCard', () => { target: { value: 'current-password-xyz' }, }); fireEvent.change(screen.getByLabelText(/^new password$/i), { - target: { value: 'short-password' }, + target: { value: 'Password1!' }, }); fireEvent.change(screen.getByLabelText(/confirm new password/i), { - target: { value: 'short-password' }, + target: { value: 'Password1!' }, }); await act(async () => { fireEvent.submit(screen.getByTestId('change-password-card')); }); const error = screen.getByTestId('change-password-local-error'); - expect(error).toHaveTextContent(/at least 8/i); - expect(error).toHaveTextContent(/3 of/i); + expect(error).toHaveTextContent(/common|another word/i); expect(cardAuthService.deriveChangePasswordKeys).not.toHaveBeenCalled(); expect(cardAuthService.changePassword).not.toHaveBeenCalled(); }); diff --git a/src/components/PasswordStrengthMeter.js b/src/components/PasswordStrengthMeter.js new file mode 100644 index 0000000..90ee6c7 --- /dev/null +++ b/src/components/PasswordStrengthMeter.js @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; + +import { + estimateVaultPasswordStrength, + MIN_VAULT_PASSWORD_LENGTH, + MIN_VAULT_PASSWORD_SCORE, + passwordStrengthFeedback, + passwordStrengthLabel, + VAULT_PASSWORD_HINT, +} from '../lib/passwordPolicy'; + +export default function PasswordStrengthMeter({ + password, + userInputs, + id, + describedBy, +}) { + const result = useMemo( + () => estimateVaultPasswordStrength(password, userInputs), + [password, userInputs] + ); + const hasPassword = typeof password === 'string' && password.length > 0; + const lengthOk = hasPassword && password.length >= MIN_VAULT_PASSWORD_LENGTH; + const scoreOk = lengthOk && result.score >= MIN_VAULT_PASSWORD_SCORE; + const label = hasPassword ? passwordStrengthLabel(result.score) : 'Start typing'; + const feedback = !hasPassword + ? VAULT_PASSWORD_HINT + : !lengthOk + ? `Use at least ${MIN_VAULT_PASSWORD_LENGTH} characters.` + : scoreOk + ? 'Looks strong enough for vault encryption.' + : passwordStrengthFeedback(result); + + return ( +
+
+ Password strength + {label} +
+
+ +
+

{feedback}

+
+ ); +} diff --git a/src/lib/passwordPolicy.js b/src/lib/passwordPolicy.js index fb3ef21..0161296 100644 --- a/src/lib/passwordPolicy.js +++ b/src/lib/passwordPolicy.js @@ -1,28 +1,60 @@ +import zxcvbn from 'zxcvbn'; + export const MIN_VAULT_PASSWORD_LENGTH = 8; -export const MIN_VAULT_PASSWORD_CLASSES = 3; +export const MIN_VAULT_PASSWORD_SCORE = 3; export const VAULT_PASSWORD_HINT = - 'Use at least 8 characters with at least 3 of: lowercase, uppercase, number, symbol.'; - -function countCharacterClasses(password) { - let count = 0; - if (/[a-z]/.test(password)) count += 1; - if (/[A-Z]/.test(password)) count += 1; - if (/[0-9]/.test(password)) count += 1; - if (/[^A-Za-z0-9]/.test(password)) count += 1; - return count; + 'Use at least 8 characters. Longer passphrases are best; weak or common passwords are rejected.'; + +export const PASSWORD_STRENGTH_LABELS = [ + 'Very weak', + 'Weak', + 'Fair', + 'Strong', + 'Very strong', +]; + +function normalizeUserInputs(userInputs) { + return (Array.isArray(userInputs) ? userInputs : []) + .map((value) => String(value || '').trim()) + .filter(Boolean); +} + +export function estimateVaultPasswordStrength(password, userInputs = []) { + return zxcvbn(String(password || ''), normalizeUserInputs(userInputs)); +} + +export function passwordStrengthLabel(score) { + const index = Math.max(0, Math.min(PASSWORD_STRENGTH_LABELS.length - 1, score)); + return PASSWORD_STRENGTH_LABELS[index]; +} + +export function passwordStrengthFeedback(result) { + if (!result || !result.feedback) return VAULT_PASSWORD_HINT; + const parts = [ + result.feedback.warning, + ...(result.feedback.suggestions || []), + ].filter(Boolean); + return parts.length > 0 ? parts.join(' ') : VAULT_PASSWORD_HINT; } -export function validateVaultPassword(password) { +export function validateVaultPassword(password, userInputs = []) { if ( typeof password !== 'string' || - password.length < MIN_VAULT_PASSWORD_LENGTH || - countCharacterClasses(password) < MIN_VAULT_PASSWORD_CLASSES + password.length < MIN_VAULT_PASSWORD_LENGTH ) { return { code: 'password_too_short', message: VAULT_PASSWORD_HINT, }; } + const result = estimateVaultPasswordStrength(password, userInputs); + if (result.score < MIN_VAULT_PASSWORD_SCORE) { + return { + code: 'password_too_weak', + message: passwordStrengthFeedback(result), + score: result.score, + }; + } return null; } diff --git a/src/lib/passwordPolicy.test.js b/src/lib/passwordPolicy.test.js new file mode 100644 index 0000000..78943c2 --- /dev/null +++ b/src/lib/passwordPolicy.test.js @@ -0,0 +1,38 @@ +import { + estimateVaultPasswordStrength, + MIN_VAULT_PASSWORD_LENGTH, + validateVaultPassword, +} from './passwordPolicy'; + +test('rejects passwords below the minimum length', () => { + expect(validateVaultPassword('A7$qP9z')).toMatchObject({ + code: 'password_too_short', + }); +}); + +test('rejects common passwords even when they meet length and class rules', () => { + const result = validateVaultPassword('Password1!'); + expect(result).toMatchObject({ code: 'password_too_weak' }); + expect(result.message).toMatch(/common|another word/i); +}); + +test('accepts strong passphrases with spaces', () => { + expect(validateVaultPassword('correct horse battery 1')).toBeNull(); +}); + +test('accepts generated passwords once estimator score is strong enough', () => { + expect(validateVaultPassword('A7$qP9z!mK')).toBeNull(); +}); + +test('feeds user inputs into the strength estimator', () => { + const emailLocalPart = 'sentryoperator'; + const password = `${emailLocalPart}2026!`; + const withoutEmail = estimateVaultPasswordStrength(password); + const withEmail = estimateVaultPasswordStrength(password, [ + `${emailLocalPart}@example.com`, + emailLocalPart, + ]); + + expect(password.length).toBeGreaterThanOrEqual(MIN_VAULT_PASSWORD_LENGTH); + expect(withEmail.guesses).toBeLessThanOrEqual(withoutEmail.guesses); +}); diff --git a/src/pages/Register.js b/src/pages/Register.js index a3656bb..9d8d327 100644 --- a/src/pages/Register.js +++ b/src/pages/Register.js @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import PageMeta from '../components/PageMeta'; +import PasswordStrengthMeter from '../components/PasswordStrengthMeter'; import { useAuth } from '../context/AuthContext'; import { isValidEmailSyntax, normalizeEmail } from '../lib/crypto/normalize'; import { @@ -13,6 +14,7 @@ import { const ERROR_COPY = { invalid_email: 'That email address doesn\'t look right — please check and try again.', password_too_short: VAULT_PASSWORD_HINT, + password_too_weak: VAULT_PASSWORD_HINT, password_mismatch: 'The passwords you entered don\'t match.', network_error: 'We couldn\'t reach the sysnode server. Check your connection and try again.', @@ -33,6 +35,7 @@ const ERROR_COPY = { const FIELDS_BY_CODE = { invalid_email: ['email'], password_too_short: ['password'], + password_too_weak: ['password'], password_mismatch: ['password', 'confirm'], invalid_body: ['email', 'password'], }; @@ -80,7 +83,7 @@ export default function Register() { }); return; } - const passwordError = validateVaultPassword(password); + const passwordError = validateVaultPassword(password, [normalized]); if (passwordError) { setError({ code: passwordError.code, @@ -116,6 +119,7 @@ export default function Register() { } const errorFields = error ? fieldsForCode(error.code) : []; + const normalizedEmail = normalizeEmail(email); if (submittedTo) { return ( @@ -221,11 +225,18 @@ export default function Register() { minLength={MIN_VAULT_PASSWORD_LENGTH} aria-invalid={errorFields.includes('password') || undefined} aria-describedby={ - errorFields.includes('password') ? 'register-alert' : undefined + errorFields.includes('password') + ? 'register-password-meter register-alert' + : 'register-password-meter' } required /> {VAULT_PASSWORD_HINT} +
diff --git a/src/pages/Register.test.js b/src/pages/Register.test.js index 48514ff..50660ec 100644 --- a/src/pages/Register.test.js +++ b/src/pages/Register.test.js @@ -43,7 +43,6 @@ test('validates password length and mismatch client-side', async () => { await userEvent.click(screen.getByRole('button', { name: /create account/i })); const weakAlert = await screen.findByRole('alert'); expect(weakAlert).toHaveTextContent(/at least 8/i); - expect(weakAlert).toHaveTextContent(/3 of/i); expect(service.register).not.toHaveBeenCalled(); const pw = screen.getByLabelText(/^password/i); @@ -57,6 +56,23 @@ test('validates password length and mismatch client-side', async () => { expect(service.register).not.toHaveBeenCalled(); }); +test('rejects common passwords and renders strength meter feedback', async () => { + const service = mockService(); + renderRegister(service); + + await userEvent.type(screen.getByLabelText(/^email/i), 'a@b.com'); + await userEvent.type(screen.getByLabelText(/^password/i), 'Password1!'); + await userEvent.type(screen.getByLabelText(/confirm password/i), 'Password1!'); + + expect(screen.getByText(/password strength/i)).toBeInTheDocument(); + expect(screen.getByText('Weak')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /create account/i })); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(/common|another word/i); + expect(service.register).not.toHaveBeenCalled(); +}); + test('flags the offending fields aria-invalid on client-side validation errors', async () => { // The alert is paired with an aria-describedby on the offending inputs // so screen readers (and sighted users via the matching red border) From 237c186336706f77c04d72d8addded91f1569d3b Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 13:22:36 -0700 Subject: [PATCH 2/4] fix(auth): address CSP and password review findings Preserve the exact default public API origin in connect-src and expand user identity inputs into useful zxcvbn dictionary tokens. Made-with: Cursor --- server.js | 20 ++++++++++++++++++-- src/lib/passwordPolicy.js | 20 +++++++++++++++++--- src/lib/passwordPolicy.test.js | 9 +++++++-- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/server.js b/server.js index 28c22b9..75539a9 100644 --- a/server.js +++ b/server.js @@ -22,11 +22,27 @@ function explicitConnectSources(value) { ); } +function connectSourceFromUrl(value) { + if (!value || String(value).startsWith('/')) return null; + try { + return new URL(value).origin; + } catch (_err) { + return null; + } +} + +const defaultPublicApiBase = + process.env.REACT_APP_API_BASE || + (process.env.NODE_ENV === 'production' + ? 'https://syscoin.dev' + : 'http://localhost:3001'); + const connectSrc = uniqueSources([ "'self'", // Key-custody pages must not allow arbitrary HTTPS exfiltration. Keep - // production same-origin by default; deployments that truly need another - // endpoint can add exact origins via SYSNODE_CSP_CONNECT_SRC. + // production same-origin by default while preserving the exact anonymous + // public API origin used by src/lib/api.js. + connectSourceFromUrl(defaultPublicApiBase), ...explicitConnectSources(process.env.SYSNODE_CSP_CONNECT_SRC), ]); diff --git a/src/lib/passwordPolicy.js b/src/lib/passwordPolicy.js index 0161296..1010ca7 100644 --- a/src/lib/passwordPolicy.js +++ b/src/lib/passwordPolicy.js @@ -15,9 +15,23 @@ export const PASSWORD_STRENGTH_LABELS = [ ]; function normalizeUserInputs(userInputs) { - return (Array.isArray(userInputs) ? userInputs : []) - .map((value) => String(value || '').trim()) - .filter(Boolean); + const tokens = []; + for (const value of Array.isArray(userInputs) ? userInputs : []) { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) continue; + + tokens.push(normalized); + + const [localPart, domain] = normalized.split('@'); + if (localPart && domain) { + tokens.push(localPart, domain); + tokens.push(...domain.split('.')); + } + + tokens.push(...normalized.split(/[^a-z0-9]+/)); + } + + return [...new Set(tokens.filter((token) => token.length >= 3))]; } export function estimateVaultPasswordStrength(password, userInputs = []) { diff --git a/src/lib/passwordPolicy.test.js b/src/lib/passwordPolicy.test.js index 78943c2..dd32870 100644 --- a/src/lib/passwordPolicy.test.js +++ b/src/lib/passwordPolicy.test.js @@ -30,9 +30,14 @@ test('feeds user inputs into the strength estimator', () => { const withoutEmail = estimateVaultPasswordStrength(password); const withEmail = estimateVaultPasswordStrength(password, [ `${emailLocalPart}@example.com`, - emailLocalPart, ]); expect(password.length).toBeGreaterThanOrEqual(MIN_VAULT_PASSWORD_LENGTH); - expect(withEmail.guesses).toBeLessThanOrEqual(withoutEmail.guesses); + expect(withEmail.guesses).toBeLessThan(withoutEmail.guesses); +}); + +test('rejects passwords built from the email local part', () => { + expect( + validateVaultPassword('sentryoperator2026!', ['sentryoperator@example.com']) + ).toMatchObject({ code: 'password_too_weak' }); }); From 942421a192472eb40cdc6da68161d26ce3fbaaa2 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 13:31:41 -0700 Subject: [PATCH 3/4] fix(api): keep production API calls same-origin Avoid CSP drift from build-time API overrides by making production clients use relative same-origin paths and limiting explicit API base overrides to non-production builds. Made-with: Cursor --- README.md | 8 +++++--- server.js | 22 +++------------------- src/lib/api.js | 11 +++++------ src/lib/apiClient.js | 8 ++++---- src/lib/apiClient.test.js | 13 +++++++++++-- 5 files changed, 28 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 6dd79b9..be540be 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Sysnode is designed to make that information easier to read, easier to verify, a ## Data Source -The frontend reads live dashboard data from the Sysnode backend API. Anonymous public data can still be retargeted with `REACT_APP_API_BASE`, but the authenticated custody surface (`/auth`, `/vault`, `/gov`) is designed for same-origin production deployment: +The frontend reads live dashboard data from the Sysnode backend API. Production is designed for same-origin deployment: ```text https://sysnode.info/ -> SPA @@ -38,13 +38,15 @@ https://sysnode.info/vault/* -> backend https://sysnode.info/gov/* -> backend ``` -The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private deployment, override the API base URL at build time (no code change required): +The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private production deployment, keep the same-origin shape and point the reverse proxy at that deployment's backend. This keeps the CSP `connect-src` policy, host-only cookies, and CSRF model in lockstep. + +For local or non-production testing, you can override the API base URL at build time: ```bash REACT_APP_API_BASE=https://your-backend.example npm run build ``` -The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats). Without it, development authenticated requests use `http://localhost:3001`; production authenticated requests use same-origin relative paths. Keeping the authenticated API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches. +The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats) outside production. Without it, development requests use `http://localhost:3001`; production requests use same-origin relative paths. Keeping the API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches. ## Getting Started diff --git a/server.js b/server.js index 75539a9..52c3c07 100644 --- a/server.js +++ b/server.js @@ -22,27 +22,11 @@ function explicitConnectSources(value) { ); } -function connectSourceFromUrl(value) { - if (!value || String(value).startsWith('/')) return null; - try { - return new URL(value).origin; - } catch (_err) { - return null; - } -} - -const defaultPublicApiBase = - process.env.REACT_APP_API_BASE || - (process.env.NODE_ENV === 'production' - ? 'https://syscoin.dev' - : 'http://localhost:3001'); - const connectSrc = uniqueSources([ "'self'", - // Key-custody pages must not allow arbitrary HTTPS exfiltration. Keep - // production same-origin by default while preserving the exact anonymous - // public API origin used by src/lib/api.js. - connectSourceFromUrl(defaultPublicApiBase), + // Key-custody pages must not allow arbitrary HTTPS exfiltration. Production + // API traffic is same-origin by default; deployments that truly need another + // endpoint can add exact origins via SYSNODE_CSP_CONNECT_SRC. ...explicitConnectSources(process.env.SYSNODE_CSP_CONNECT_SRC), ]); diff --git a/src/lib/api.js b/src/lib/api.js index 8124863..dbb4472 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -14,15 +14,14 @@ import axios from 'axios'; // flips true. Codex PR20 round 7 P1. // // Priority (mirrors apiClient.js): -// 1. REACT_APP_API_BASE (build-time override for bespoke deployments) -// 2. Production builds → https://syscoin.dev (the default -// hosted backend, same host the authenticated client picks up) +// 1. Production builds → same-origin relative paths. The deployment +// reverse-proxies these anonymous endpoints next to the SPA. +// 2. Non-production REACT_APP_API_BASE override for local/bespoke testing. // 3. Development builds → http://localhost:3001 (backend dev server) const DEFAULT_BASE = - process.env.REACT_APP_API_BASE || (process.env.NODE_ENV === 'production' - ? 'https://syscoin.dev' - : 'http://localhost:3001'); + ? '' + : process.env.REACT_APP_API_BASE || 'http://localhost:3001'); const client = axios.create({ baseURL: DEFAULT_BASE, diff --git a/src/lib/apiClient.js b/src/lib/apiClient.js index e2775b5..c7e6987 100644 --- a/src/lib/apiClient.js +++ b/src/lib/apiClient.js @@ -21,18 +21,18 @@ import axios from 'axios'; // Default API base URL. // // Priority: -// 1. REACT_APP_API_BASE (build-time override for bespoke deployments) -// 2. Production builds → same-origin relative paths. Production must +// 1. Production builds → same-origin relative paths. Production must // reverse-proxy /auth, /vault, and /gov under the SPA origin so // host-only SameSite=Lax cookies and the readable csrf cookie work // without cross-site credentialed fetches. +// 2. Non-production REACT_APP_API_BASE override for local/bespoke testing. // 3. Development builds → http://localhost:3001 (backend dev server) export function resolveDefaultApiBase({ apiBase = process.env.REACT_APP_API_BASE, nodeEnv = process.env.NODE_ENV, } = {}) { - if (apiBase) return apiBase; - return nodeEnv === 'production' ? '' : 'http://localhost:3001'; + if (nodeEnv === 'production') return ''; + return apiBase || 'http://localhost:3001'; } const DEFAULT_BASE = resolveDefaultApiBase(); diff --git a/src/lib/apiClient.test.js b/src/lib/apiClient.test.js index 455e971..d3c076f 100644 --- a/src/lib/apiClient.test.js +++ b/src/lib/apiClient.test.js @@ -40,13 +40,13 @@ describe('readCsrfCookie', () => { }); describe('resolveDefaultApiBase', () => { - test('uses explicit REACT_APP_API_BASE override', () => { + test('keeps production authenticated calls same-origin even with an override', () => { expect( resolveDefaultApiBase({ apiBase: 'https://api.example.test', nodeEnv: 'production', }) - ).toBe('https://api.example.test'); + ).toBe(''); }); test('defaults production authenticated calls to same-origin relative paths', () => { @@ -60,6 +60,15 @@ describe('resolveDefaultApiBase', () => { 'http://localhost:3001' ); }); + + test('uses explicit REACT_APP_API_BASE override outside production', () => { + expect( + resolveDefaultApiBase({ + apiBase: 'https://api.example.test', + nodeEnv: 'development', + }) + ).toBe('https://api.example.test'); + }); }); describe('createApiClient — CSRF attachment', () => { From 9776d4733d98a8f07906cbc4e8172c5447bf5b9d Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 13:41:46 -0700 Subject: [PATCH 4/4] docs(api): clarify same-origin proxy routes Document the anonymous API routes required by production same-origin deployments and use a dev-server example for non-production API base overrides. Made-with: Cursor --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index be540be..a98afd8 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,17 @@ https://sysnode.info/ -> SPA https://sysnode.info/auth/* -> backend https://sysnode.info/vault/* -> backend https://sysnode.info/gov/* -> backend +https://sysnode.info/mnStats -> backend +https://sysnode.info/mnCount -> backend +https://sysnode.info/govlist -> backend ``` The backend aggregates data from a Syscoin Core node, Sentry Node RPC responses, market APIs, and supporting network datasets. For a fork or private production deployment, keep the same-origin shape and point the reverse proxy at that deployment's backend. This keeps the CSP `connect-src` policy, host-only cookies, and CSRF model in lockstep. -For local or non-production testing, you can override the API base URL at build time: +For local or non-production testing, you can override the API base URL while running the dev server: ```bash -REACT_APP_API_BASE=https://your-backend.example npm run build +REACT_APP_API_BASE=https://your-backend.example npm start ``` The value is read at build time by both `src/lib/apiClient.js` (authenticated surface) and `src/lib/api.js` (anonymous surface — superblock timing, governance feed, masternode stats) outside production. Without it, development requests use `http://localhost:3001`; production requests use same-origin relative paths. Keeping the API under the SPA origin preserves host-only `Secure; SameSite=Lax` cookies and lets the SPA mirror the CSRF cookie into `X-CSRF-Token` without cross-site credentialed fetches.