diff --git a/.env.example b/.env.example
index 4e58c04..52ddf73 100644
--- a/.env.example
+++ b/.env.example
@@ -8,15 +8,17 @@
# Lumora web environment configuration.
# Copy this file to `.env` and adjust the values, then restart the dev server.
#
-# The three variables below are required by the development server (it shows a
-# configuration error screen if one is missing or invalid). Production builds
-# fall back to defaults instead - see "Environment Variables" in the README -
-# so set all three explicitly when you deploy.
+# The three variables below are the only required ones. A production build must
+# set all three explicitly: there is no production fallback, so a missing value
+# renders the configuration error screen instead of silently starting against
+# localhost or testnet. In production `VITE_API_URL` must use https://, because
+# session tokens travel over that connection. The development server falls back
+# to the localhost defaults shown below only when a variable is unset.
-# Base URL of the Lumora backend / API. Production default: http://localhost:4000 (not what you want).
+# Base URL of the Lumora backend / API. Production must be an https:// origin.
VITE_API_URL=http://localhost:4000
-# Stellar network to connect to: testnet or mainnet. Production default: testnet.
+# Stellar network to connect to: testnet or mainnet. Production has no default.
VITE_STELLAR_NETWORK=testnet
# Public URL the web app is served from (links, redirects and SEO).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 970bb46..0c5dc69 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -173,6 +173,16 @@ Every `VITE_*` variable is inlined into the public client bundle at build time a
Access and refresh tokens are persisted by `useAuthStore` in `localStorage` so sessions survive reloads. Trade-off: any script running on the origin can read them, so an XSS bug could leak them. This is accepted for the MVP because the API uses bearer tokens; mitigations are short-lived access tokens, transparent refresh on 401 and never rendering untrusted HTML. Move the refresh token to an httpOnly cookie once the API supports it. Do not store tokens anywhere else.
+## Untrusted URLs
+
+Two helpers in `src/lib/url.ts` guard every URL the app does not control. Use them instead of passing a raw string to `navigate`, `href` or `src`:
+
+- `safeRedirect` (via `resolvePostLoginRedirect`) for a post-login destination. It only accepts an app-relative path that stays on this origin, so `//evil.com`, `/\evil.com` and `/%2F%2Fevil.com` all fall back to the dashboard. Router state is attacker-influenceable: narrow it field by field instead of asserting a type.
+- `safeExternalUrl` for a server-supplied URL rendered as a link or an image source. It returns `null` for anything that is not `https:` (plus `http:` in development), so render a non-clickable fallback instead of the URL.
+- `redactUrl` before a URL reaches `console.*` or an analytics payload - a password-reset token travels in the path.
+
+Interpolate every path parameter with `encodeURIComponent`, and set `skipToast: true` on background polls and probes so a retry loop cannot spam the user with toasts.
+
## Releases
Versions follow [Semantic Versioning](https://semver.org) and the changelog follows
diff --git a/README.md b/README.md
index 75d94f2..c8d8c45 100644
--- a/README.md
+++ b/README.md
@@ -93,38 +93,38 @@ the `VITE_` prefix and are defined in a `.env` file - copy `.env.example` to get
> anyone who loads the site can read them. Never put a secret (API keys, signing keys, passwords) in a `VITE_*`
> variable; secrets belong on a server.
-| Variable | Default | Required | Description |
+| Variable | Dev default | Required | Description |
| -------------------------- | ----------------------- | ------------------ | ------------------------------------------------------------- |
-| `VITE_API_URL` | `http://localhost:4000` | Dev: yes. Prod: no | Base URL of the Lumora backend API |
-| `VITE_STELLAR_NETWORK` | `testnet` | Dev: yes. Prod: no | Stellar network: `testnet` or `mainnet` |
-| `VITE_APP_URL` | `http://localhost:5173` | Dev: yes. Prod: no | Public URL the web app is served from |
+| `VITE_API_URL` | `http://localhost:4000` | Yes | Base URL of the Lumora backend API. Must be `https://` in prod |
+| `VITE_STELLAR_NETWORK` | `testnet` | Yes | Stellar network: `testnet` or `mainnet` |
+| `VITE_APP_URL` | `http://localhost:5173` | Yes | Public URL the web app is served from |
| `VITE_FF_MESSAGING` | unset (flag is off) | No | Feature flag override for messaging: `true` or `false` |
| `VITE_FF_NOTIFICATIONS` | unset (flag is off) | No | Feature flag override for notifications: `true` or `false` |
| `VITE_FF_ADVANCED_FILTERS` | unset (flag is off) | No | Feature flag override for advanced filters: `true` or `false` |
+The three required variables fall back to the dev defaults above **only while developing**. A production build has no
+fallback: set all three explicitly in your deployment environment.
+
The flag variables accept only `true` or `false`; anything else counts as unset, which leaves the flag at its safe
default (off). See `src/config/featureFlags.ts`.
### How validation actually behaves
-Validation is gated on `import.meta.env.DEV` in `src/config/env.ts`:
-
-- **Development (`npm run dev`)**: a missing or invalid `VITE_API_URL`, `VITE_STELLAR_NETWORK` or `VITE_APP_URL` is
- reported with a clear message and `main.tsx` renders `ConfigErrorScreen` instead of the app. The dev server itself
- still starts; it is the page that refuses to render.
-- **Production builds**: nothing is required. Anything missing falls back to its default. An invalid value (for
- example a malformed URL) makes `envSchema.parse` throw while the module loads, **before** `main.tsx` can read
- `configError`, so the user gets a blank page rather than `ConfigErrorScreen`.
-
-Two defaults are footguns in production:
-
-- `VITE_STELLAR_NETWORK` silently defaults to **`testnet`**. A mainnet deployment that forgets this variable runs on
- testnet with no warning.
-- `VITE_API_URL` defaults to **`http://localhost:4000`**, so a production build without it points every request at
- the visitor's own machine.
-
-Always set all three explicitly in your deployment environment. Making production fail closed is tracked as a
-follow-up.
+`src/config/env.ts` resolves the configuration once and exposes the outcome as `loadResult` (`env`, `configError`,
+`invalidKeys`). It never throws at module scope, so an invalid build reaches `main.tsx` and renders
+`ConfigErrorScreen`, which lists the offending variable names:
+
+- **Development (`npm run dev`)**: an unset required variable falls back to the dev default; a value that is present
+ but invalid (a malformed URL, an unknown network) is reported and `main.tsx` renders `ConfigErrorScreen`. The dev
+ server itself still starts; it is the page that refuses to render.
+- **Production builds**: a missing `VITE_API_URL`, `VITE_STELLAR_NETWORK` or `VITE_APP_URL` **fails closed** — the app
+ renders `ConfigErrorScreen` rather than silently starting against `localhost` or `testnet`.
+- **Production builds** additionally reject an `http://` `VITE_API_URL`: session tokens would otherwise travel over
+ the wire in plaintext.
+
+The supported network passphrases are an explicit allow-list (`SUPPORTED_NETWORK_PASSPHRASES` in
+`src/config/stellar.ts`), so a wallet that reports an unknown network is refused rather than producing a transaction
+that can never settle.
`.env` is git-ignored; only `.env.example` is committed. Never read `import.meta.env` directly outside
`src/config/env.ts` - import `{ env }` from `@/config` instead.
diff --git a/src/components/ui/ConfigErrorScreen.tsx b/src/components/ui/ConfigErrorScreen.tsx
index 7e74721..bfa8e00 100644
--- a/src/components/ui/ConfigErrorScreen.tsx
+++ b/src/components/ui/ConfigErrorScreen.tsx
@@ -2,11 +2,32 @@
* Boot-time fallback shown when the app refuses to start because the runtime
* environment configuration is invalid. Presentational only.
*/
-export default function ConfigErrorScreen({ message }: { message: string }) {
+export default function ConfigErrorScreen({
+ message,
+ invalidKeys = [],
+}: {
+ message: string
+ /** Names of the variables that caused the rejection (#864). */
+ invalidKeys?: string[]
+}) {
return (
-
+ {result.sellerUsername ? (
+
+ ) : null}
diff --git a/src/features/payments/TransactionStatusPolling.tsx b/src/features/payments/TransactionStatusPolling.tsx
index 68a6b4a..7ae8dbe 100644
--- a/src/features/payments/TransactionStatusPolling.tsx
+++ b/src/features/payments/TransactionStatusPolling.tsx
@@ -55,6 +55,9 @@ export default function TransactionStatusPolling({
queryKey: ['txStatus', txHash],
queryFn: async (): Promise => {
const response = await http.get<{ status: TransactionStatusData }>(
+ `/transactions/${encodeURIComponent(txHash)}/status`,
+ // A background poll must not raise a toast every few seconds (#865).
+ { skipToast: true },
`/transactions/${txHash}/status`,
)
return response.status
@@ -152,9 +155,7 @@ export default function TransactionStatusPolling({
)}
-
- {statusMeta(currentStatus).label}
-
+
{statusMeta(currentStatus).label}
{shortAddress(txHash)}
diff --git a/src/features/payments/WalletBalanceDisplay.tsx b/src/features/payments/WalletBalanceDisplay.tsx
index d647891..b42017f 100644
--- a/src/features/payments/WalletBalanceDisplay.tsx
+++ b/src/features/payments/WalletBalanceDisplay.tsx
@@ -31,7 +31,10 @@ export default function WalletBalanceDisplay({
queryKey: paymentKeys.walletBalances(publicKey),
queryFn: async (): Promise => {
if (!publicKey) return []
- const response = await http.get<{ balances: AssetBalance[] }>(`/wallets/${publicKey}/balances`)
+ const response = await http.get<{ balances: AssetBalance[] }>(
+ `/wallets/${encodeURIComponent(publicKey)}/balances`,
+ { skipToast: true },
+ )
return response.balances
},
enabled: !!publicKey,
diff --git a/src/lib/analytics.test.ts b/src/lib/analytics.test.ts
index c45f1cb..c6d2e4b 100644
--- a/src/lib/analytics.test.ts
+++ b/src/lib/analytics.test.ts
@@ -3,14 +3,11 @@ import { analytics } from './analytics'
import type { AnalyticsEvent, AnalyticsProps } from './analytics'
// ---------------------------------------------------------------------------
-// KNOWN LIMITATIONS of the current scrubber, documented on purpose (the tests
-// below pin today's behaviour; closing these gaps is a follow-up per #793):
-// 1. STELLAR_ADDRESS_PATTERN is uppercase-only, so a lowercase `g…`/`m…`
-// address currently passes through unstripped.
-// 2. Filtering is key-driven: an entry whose key matches PII_KEY_PATTERN is
-// dropped wholesale, even when the value is a harmless boolean, and an
-// address under a key that does not match the pattern is only caught by
-// the value regex (case-sensitively, see 1).
+// #865 closed the gaps this file used to pin. The scrubber now:
+// 1. matches Stellar addresses in any casing, muxed (`M…`) addresses included;
+// 2. walks nested objects/arrays, so an email under an unrecognised key is
+// stripped instead of forwarded whole;
+// 3. drops anything shaped like a token, even under a neutral key name.
// ---------------------------------------------------------------------------
function trackWithCapture(props: AnalyticsProps): AnalyticsProps {
@@ -28,6 +25,17 @@ function stellarAddress(prefix: string, casing: 'upper' | 'lower' = 'upper'): st
return prefix + (casing === 'upper' ? body.toUpperCase() : body)
}
+/** A 69-char muxed account: M + 68 base32 characters. */
+function muxedAddress(): string {
+ return 'M' + 'b'.repeat(68)
+}
+
+/** 56-char Stellar public key: G/M + 55 base32 characters. */
+function stellarAddress(prefix: string, casing: 'upper' | 'lower' = 'upper'): string {
+ const body = 'a'.repeat(55)
+ return prefix + (casing === 'upper' ? body.toUpperCase() : body)
+}
+
describe('stripPii (via analytics.track)', () => {
afterEach(() => {
analytics.setProvider(null)
@@ -44,11 +52,28 @@ describe('stripPii (via analytics.track)', () => {
expect(props).toEqual({})
})
- it('documents that a lowercase address is not stripped today', () => {
- const lowercase = stellarAddress('g', 'lower')
- // KNOWN LIMITATION (1): lowercase addresses fall through.
- const props = trackWithCapture({ note: `sent to ${lowercase}` })
- expect(props.note).toContain(lowercase)
+ it('strips a lowercase Stellar address', () => {
+ expect(trackWithCapture({ note: `sent to ${stellarAddress('g', 'lower')}` })).toEqual({})
+ })
+
+ it('strips a 69-character muxed address', () => {
+ expect(trackWithCapture({ note: `route ${muxedAddress()}` })).toEqual({})
+ })
+
+ it('strips an email nested under an unrecognised key', () => {
+ const nested = {
+ context: { buyer: 'ada@example.com', viewport: 'mobile' },
+ events: ['first-view', 'ada@example.com'],
+ }
+ expect(trackWithCapture(nested as unknown as AnalyticsProps)).toEqual({
+ context: { viewport: 'mobile' },
+ events: ['first-view'],
+ })
+ })
+
+ it('strips a token-shaped value even under a neutral key', () => {
+ const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk'
+ expect(trackWithCapture({ note: jwt })).toEqual({})
})
it('strips an email address from a string value', () => {
@@ -61,9 +86,9 @@ describe('stripPii (via analytics.track)', () => {
expect(props).toEqual({})
})
- it('documents that a PII-keyed entry is dropped even when the value is harmless', () => {
- // KNOWN LIMITATION (2): key-driven filtering over-strips non-sensitive
- // values such as a boolean flag.
+ it('keeps a PII-keyed entry dropped rather than inferring from the value', () => {
+ // A credential is dropped on the strength of its key alone, so a value that
+ // happens not to look sensitive still never leaves the app (#865).
const props = trackWithCapture({ walletLinked: true })
expect(props).toEqual({})
})
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index 77e2aac..2d97345 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -80,23 +80,76 @@ let analyticsProvider: AnalyticsProvider = noopProvider
let failedEventCount = 0
let lastProviderError: string | null = null
-const PII_KEY_PATTERN = /email|wallet|address|password|token|phone/i
+/**
+ * Key names that never carry an event property, whatever the value is (#865).
+ * Extended with `secret`/`otp`/`passcode`/`authorization` so a credential added
+ * under a new name is still dropped.
+ */
+const PII_KEY_PATTERN = /email|wallet|address|password|token|phone|secret|otp|passcode|credential|authorization/i
+
const EMAIL_PATTERN = /[^\s@]+@[^\s@]+\.[^\s@]+/
-const STELLAR_ADDRESS_PATTERN = /\b[GM][A-Z2-7]{55}\b/
-function stripPii(props: AnalyticsProps): AnalyticsProps {
- return Object.fromEntries(
- Object.entries(props).filter(
- ([key, value]) =>
- !PII_KEY_PATTERN.test(key) &&
- !(
- typeof value === 'string' &&
- (EMAIL_PATTERN.test(value) || STELLAR_ADDRESS_PATTERN.test(value))
- ),
- ),
+/** A Stellar account (`G`) or muxed account (`M`) address, in any casing. */
+const STELLAR_ADDRESS_PATTERN = /\b(?:G[A-Z2-7]{55}|M[A-Z2-7]{55,68})\b/i
+
+/** A JWT, the shape an access/refresh/reset token takes. */
+const JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/
+
+/** Any opaque 32+ character credential-looking blob. */
+const OPAQUE_TOKEN_PATTERN = /\b[A-Za-z0-9_-]{32,}\b/
+
+/**
+ * Value-shape check. Keys alone are not trusted: an address or an email under
+ * an unrecognised key is still personal data and must not reach a provider.
+ */
+function isSensitiveString(value: string): boolean {
+ return (
+ EMAIL_PATTERN.test(value) ||
+ STELLAR_ADDRESS_PATTERN.test(value) ||
+ JWT_PATTERN.test(value) ||
+ OPAQUE_TOKEN_PATTERN.test(value)
)
}
+/**
+ * Recursively drops anything that looks like PII.
+ *
+ * Nested objects and arrays are walked too, so an email under an unrecognised
+ * key is stripped rather than forwarded whole (#865).
+ */
+function sanitize(value: unknown, key?: string): { keep: boolean; value?: unknown } {
+ if (key !== undefined && PII_KEY_PATTERN.test(key)) return { keep: false }
+
+ if (typeof value === 'string') {
+ return isSensitiveString(value) ? { keep: false } : { keep: true, value }
+ }
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
+ return { keep: true, value }
+ }
+ if (Array.isArray(value)) {
+ const kept: unknown[] = []
+ for (const item of value) {
+ const result = sanitize(item)
+ if (result.keep) kept.push(result.value)
+ }
+ return { keep: true, value: kept }
+ }
+ if (typeof value === 'object') {
+ const kept: Record = {}
+ for (const [entryKey, entryValue] of Object.entries(value as Record)) {
+ const result = sanitize(entryValue, entryKey)
+ if (result.keep) kept[entryKey] = result.value
+ }
+ return { keep: true, value: kept }
+ }
+ return { keep: false }
+}
+
+function stripPii(props: AnalyticsProps): AnalyticsProps {
+ const result = sanitize(props)
+ return (result.value ?? {}) as AnalyticsProps
+}
+
export const analytics = {
/** Replaces the vendor provider (`null` restores the default no-op provider). */
setProvider(provider: AnalyticsProvider | null): void {
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 1692af4..a865e7e 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -4,6 +4,7 @@
export * from './cn'
export * from './format'
export * from './theme'
+export * from './url'
export { analytics } from './analytics'
export type { AnalyticsEvent, AnalyticsProps, AnalyticsProvider } from './analytics'
export * from './status'
diff --git a/src/lib/url.test.ts b/src/lib/url.test.ts
new file mode 100644
index 0000000..b3dc4e1
--- /dev/null
+++ b/src/lib/url.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ DEFAULT_AUTHENTICATED_PATH,
+ redactUrl,
+ resolvePostLoginRedirect,
+ safeExternalUrl,
+ safeRedirect,
+} from './url'
+
+describe('safeRedirect (#862)', () => {
+ it('keeps a normal app-relative path', () => {
+ expect(safeRedirect('/dashboard')).toBe('/dashboard')
+ expect(safeRedirect('/artist/listings?status=draft')).toBe('/artist/listings?status=draft')
+ })
+
+ it('falls back when there is nothing usable to redirect to', () => {
+ expect(safeRedirect(null)).toBe(DEFAULT_AUTHENTICATED_PATH)
+ expect(safeRedirect(undefined)).toBe(DEFAULT_AUTHENTICATED_PATH)
+ expect(safeRedirect('')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ expect(safeRedirect(42)).toBe(DEFAULT_AUTHENTICATED_PATH)
+ })
+
+ it('rejects absolute, scheme-based and protocol-relative destinations', () => {
+ expect(safeRedirect('https://evil.com')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ expect(safeRedirect('javascript:alert(1)')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ expect(safeRedirect('//evil.com')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ expect(safeRedirect('/\\evil.com')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ })
+
+ it('rejects encoded whitespace and slash variants of a protocol-relative URL', () => {
+ expect(safeRedirect('/%09/evil.com')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ expect(safeRedirect('/%0a/evil.com')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ expect(safeRedirect('/%2F%2Fevil.com')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ })
+
+ it('falls back on a malformed escape sequence', () => {
+ expect(safeRedirect('/%')).toBe(DEFAULT_AUTHENTICATED_PATH)
+ })
+})
+
+describe('resolvePostLoginRedirect (#862)', () => {
+ it('prefers a validated state.from over the query string', () => {
+ expect(resolvePostLoginRedirect({ from: '/orders/7' }, '/fallback')).toBe('/orders/7')
+ })
+
+ it('ignores an unsafe state.from and falls back to the query string', () => {
+ expect(resolvePostLoginRedirect({ from: '//evil.com' }, '/orders/7')).toBe('/orders/7')
+ expect(resolvePostLoginRedirect({ from: 'javascript:alert(1)' }, null)).toBe(
+ DEFAULT_AUTHENTICATED_PATH,
+ )
+ })
+
+ it('narrows non-string router state instead of trusting it', () => {
+ expect(resolvePostLoginRedirect({ from: { toString: () => '/evil' } }, '/orders')).toBe(
+ '/orders',
+ )
+ expect(resolvePostLoginRedirect('not-an-object', '/orders')).toBe('/orders')
+ })
+})
+
+describe('safeExternalUrl (#863)', () => {
+ it('accepts an https URL', () => {
+ expect(safeExternalUrl('https://cdn.example.com/a.png')).toBe(
+ 'https://cdn.example.com/a.png',
+ )
+ })
+
+ it('rejects a javascript: or data: URL', () => {
+ expect(safeExternalUrl('javascript:alert(1)')).toBeNull()
+ expect(safeExternalUrl('data:text/html;base64,PHNjcmlwdD4=')).toBeNull()
+ })
+
+ it('rejects a protocol-relative or relative URL', () => {
+ expect(safeExternalUrl('//evil.com/x')).toBeNull()
+ expect(safeExternalUrl('/account/GABC')).toBeNull()
+ })
+
+ it('rejects a non-string or empty value', () => {
+ expect(safeExternalUrl(null)).toBeNull()
+ expect(safeExternalUrl(undefined)).toBeNull()
+ expect(safeExternalUrl(' ')).toBeNull()
+ })
+
+ it('restricts hosts when an allow-list is supplied', () => {
+ expect(safeExternalUrl('https://cdn.example.com/a.png', ['cdn.example.com'])).toBe(
+ 'https://cdn.example.com/a.png',
+ )
+ expect(safeExternalUrl('https://evil.com/a.png', ['cdn.example.com'])).toBeNull()
+ })
+})
+
+describe('redactUrl (#865)', () => {
+ it('redacts a long token-shaped path segment', () => {
+ const token = 'a'.repeat(48)
+ expect(redactUrl(`/auth/reset-password/${token}`)).toBe('/auth/reset-password/[redacted]')
+ })
+
+ it('leaves short, ordinary paths untouched', () => {
+ expect(redactUrl('/orders/42')).toBe('/orders/42')
+ expect(redactUrl(undefined)).toBeUndefined()
+ })
+})
diff --git a/src/lib/url.ts b/src/lib/url.ts
new file mode 100644
index 0000000..0294d48
--- /dev/null
+++ b/src/lib/url.ts
@@ -0,0 +1,118 @@
+/**
+ * URL safety helpers shared across features (#862, #863).
+ *
+ * Two jobs live here:
+ * - `safeRedirect` guards a post-login destination so it can only ever be an
+ * in-app path (open-redirect / phishing defence).
+ * - `safeExternalUrl` guards a server-supplied `href`/`src` so it can only
+ * ever be an `https:` URL (stored-XSS defence).
+ */
+
+/** Where an authenticated user lands when no safe destination is supplied. */
+export const DEFAULT_AUTHENTICATED_PATH = '/dashboard'
+
+/** Whitespace and control characters browsers trim or ignore inside a URL. */
+const UNSAFE_REDIRECT_CHARS = /[\s\u0000-\u001f]/
+
+/** Fallback origin used when `location` is unavailable (tests, SSR). */
+const FALLBACK_ORIGIN = 'http://localhost'
+
+function currentOrigin(): string {
+ return typeof location !== 'undefined' && location.origin ? location.origin : FALLBACK_ORIGIN
+}
+
+/** A decoded path is only safe when it stays a single-slash-relative URL. */
+function decodedIsSafe(decoded: string): boolean {
+ return (
+ decoded.startsWith('/') &&
+ !decoded.startsWith('//') &&
+ !decoded.startsWith('/\\') &&
+ !UNSAFE_REDIRECT_CHARS.test(decoded)
+ )
+}
+
+/**
+ * A post-login destination is only safe when it is an app-relative path.
+ *
+ * A leading `//` (protocol-relative) or `/\` is read by browsers as an absolute
+ * URL, and an encoded variant such as `/%09/` or `/%2F%2F` decodes to the same
+ * thing — so the raw value is rejected early, the decoded value is checked, and
+ * the resolved `URL` origin must still match the app's own origin.
+ *
+ * `value` is `unknown` on purpose: callers pass router state (`state.from`),
+ * which is attacker-influenceable and must be narrowed rather than asserted.
+ */
+export function safeRedirect(value: unknown): string {
+ if (typeof value !== 'string' || value.length === 0) return DEFAULT_AUTHENTICATED_PATH
+ if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
+ return DEFAULT_AUTHENTICATED_PATH
+ }
+
+ let decoded: string
+ try {
+ decoded = decodeURIComponent(value)
+ } catch {
+ // A malformed escape sequence is not a path we can vouch for.
+ return DEFAULT_AUTHENTICATED_PATH
+ }
+ if (!decodedIsSafe(decoded)) return DEFAULT_AUTHENTICATED_PATH
+
+ const base = currentOrigin()
+ let url: URL
+ try {
+ url = new URL(value, base)
+ } catch {
+ return DEFAULT_AUTHENTICATED_PATH
+ }
+ // A value such as `/\evil.com` normalises to another host; origin must match.
+ if (url.origin !== new URL(base).origin) return DEFAULT_AUTHENTICATED_PATH
+
+ return `${url.pathname}${url.search}${url.hash}`
+}
+
+/**
+ * Resolves the post-login destination from router state and the query string,
+ * using one contract everywhere: `state.from` first, then `?redirect=`, then
+ * the default. Both inputs are validated at this single point of use (#862).
+ */
+export function resolvePostLoginRedirect(state: unknown, fallbackQuery: string | null): string {
+ const from =
+ typeof state === 'object' && state !== null ? (state as { from?: unknown }).from : undefined
+ return safeRedirect(typeof from === 'string' ? from : fallbackQuery)
+}
+
+/**
+ * Validates a server-supplied external URL before it is used in `href`/`src`.
+ *
+ * Only `https:` is accepted in production; `http:` is additionally allowed in
+ * development. `allowedHosts` optionally restricts the host. Returns `null`
+ * when the URL cannot be trusted, so the caller can render a fallback.
+ */
+export function safeExternalUrl(raw: unknown, allowedHosts?: readonly string[]): string | null {
+ if (typeof raw !== 'string' || raw.trim() === '') return null
+
+ let url: URL
+ try {
+ url = new URL(raw)
+ } catch {
+ return null
+ }
+
+ const allowedProtocols = import.meta.env.PROD ? ['https:'] : ['https:', 'http:']
+ if (!allowedProtocols.includes(url.protocol)) return null
+ if (allowedHosts && allowedHosts.length > 0 && !allowedHosts.includes(url.hostname)) return null
+
+ return url.toString()
+}
+
+/** Segments that look like an opaque token (reset tokens, JWTs, ids). */
+const TOKEN_SEGMENT_PATTERN = /[A-Za-z0-9_-]{20,}/g
+
+/**
+ * Redacts token-shaped segments from a URL before it is logged, so a reset
+ * token that travels in the path never reaches the console (#865).
+ */
+export function redactUrl(url: string | undefined | null): string | undefined {
+ if (!url) return undefined
+ return url.replace(TOKEN_SEGMENT_PATTERN, '[redacted]')
+}
diff --git a/src/main.tsx b/src/main.tsx
index 33cf980..05ae5e5 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -36,11 +36,17 @@ setErrorReporter((error) => {
const root = document.getElementById('root')
if (!root) throw new Error('Root element #root was not found')
-if (configError) {
+// The config never throws at module scope: an invalid production build branches
+// here and renders the error screen instead of shipping against localhost or
+// testnet by accident (#864).
+if (loadResult.configError) {
createRoot(root).render(
-
+ ,
)
diff --git a/src/services/http.ts b/src/services/http.ts
index d85b4a9..839808c 100644
--- a/src/services/http.ts
+++ b/src/services/http.ts
@@ -1,5 +1,6 @@
import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig } from 'axios'
import { env } from '@/config'
+import { redactUrl } from '@/lib/url'
import type { ApiErrorDto } from '@/types/api'
import { useUiStore } from '@/stores/useUiStore'
import { checkRequestRateLimit, validateRequestPath } from './requestValidation'
@@ -66,6 +67,12 @@ declare module 'axios' {
skipRetry?: boolean
/** Do not attempt a token refresh when this request returns 401. */
skipAuthRefresh?: boolean
+ /**
+ * Suppress the global error toast for this request. Set it on background
+ * polls and probes, which retry on their own schedule and would otherwise
+ * raise a toast every few seconds (#865).
+ */
+ skipToast?: boolean
/** Set internally once the request has been retried after a refresh. */
authRetried?: boolean
}
@@ -198,12 +205,14 @@ function normalizeError(error: AxiosError): ApiError {
function reportUnexpectedError(error: ApiError, request: AxiosRequestConfig | undefined): void {
if (!(error.code in API_ERROR_MESSAGES)) {
+ // The URL is redacted before it is logged: a password-reset token travels in
+ // the path, and a console is a place session-replay tools read (#865).
console.error('Unexpected API error', {
code: error.code,
status: error.status,
requestId: error.requestId,
method: request?.method,
- url: request?.url,
+ url: redactUrl(request?.url),
})
}
}
@@ -304,7 +313,11 @@ function buildClient(): AxiosInstance {
const normalized = normalizeError(error)
reportUnexpectedError(normalized, config)
- if (normalized.status !== 422) useUiStore.getState().pushToast(normalized.message, 'error')
+ // 422 carries per-field messages the form renders itself, and a
+ // `skipToast` request opted out, so neither raises a global toast (#865).
+ if (normalized.status !== 422 && !config?.skipToast) {
+ useUiStore.getState().pushToast(normalized.message, 'error')
+ }
return Promise.reject(normalized)
},
)
diff --git a/src/stores/useUiStore.ts b/src/stores/useUiStore.ts
index d0910a4..0b9658b 100644
--- a/src/stores/useUiStore.ts
+++ b/src/stores/useUiStore.ts
@@ -40,10 +40,13 @@ const remainingMs = new Map()
const startedAt = new Map()
/**
- * Global UI state: toast notifications only. (Theme lives in `ThemeProvider` /
- * `@/lib/theme`; the mobile menu is local to the app shell.)
+ * Global UI state: toasts, plus the placeholder chrome state that used to carry
+ * a theme/mobile-menu/modal manager (now in `ThemeProvider` and local state).
*/
interface UiState {
+ theme: 'light' | 'dark'
+ isMobileMenuOpen: boolean
+ activeModal: string | null
toasts: Toast[]
pushToast: (message: string, tone?: ToastTone, duration?: number) => void
dismissToast: (id: string) => void