From a0d8876550f8fb4f64583be1177c37448a16f98f Mon Sep 17 00:00:00 2001
From: Maryam Ibrahim Hamza <119738147+Maryermarh@users.noreply.github.com>
Date: Mon, 19 Oct 2026 00:53:29 +0100
Subject: [PATCH] Harden auth redirects, server-supplied URLs and runtime
config
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #862, #863, #864, #865.
#862 - validate every post-login redirect through one shared helper
- add `safeRedirect`/`resolvePostLoginRedirect` to `src/lib/url.ts`: rejects
`//evil.com`, `/\evil.com`, `/%09/…`, `/%2F%2F…`, scheme URLs and malformed
escapes, and additionally requires the resolved `URL` origin to match the
app's own origin.
- `SignInPage` no longer navigates to a raw `?redirect=`/`state.from`; it narrows
the attacker-influenceable router state field by field instead of asserting it,
and only renders a `notice` when it is a string.
- `LoginPage`/`GuestRoute`/`SignInPage` now share one contract: `state.from`,
then `?redirect=`, then the dashboard default, validated at the single point
of use. This fixes the app-wide broken post-login redirect (`RequireAuth` set
`state.from` that `LoginPage` never read).
- tests: `src/lib/url.test.ts` plus the existing auth utils suite.
#863 - validate server-supplied URLs before rendering them as links/sources
- add `safeExternalUrl(raw, allowedHosts?)`: parses with `new URL`, accepts only
`https:` (plus `http:` in dev) and an optional host allow-list, else `null`.
- `PaymentResultScreen`/`OrderConfirmationScreen` deliverable links render a
non-clickable fallback when the API returns a `javascript:`/`data:` URL.
- `AccountMenu` validates `user.avatarUrl` before it reaches ``.
- `encodeURIComponent` every interpolated path parameter (explorer links, order/
payment result, transaction status, wallet balances, asset selector) and the
seller profile route now uses the real `sellerUsername` instead of a
slugified display name.
- tests: `safeExternalUrl` covers `javascript:`, `data:`, protocol-relative,
off-host and non-string inputs.
#864 - fail closed in production when the config is missing or wrong
- remove the production localhost/testnet defaults; required variables have no
production fallback and dev defaults apply only outside a prod build.
- a production build rejects an `http://` `VITE_API_URL` (tokens would travel in
plaintext).
- the config no longer throws at module scope: it exports `loadResult`
(`env`/`configError`/`invalidKeys`) and `main.tsx` branches on it, so an
invalid build renders `ConfigErrorScreen` instead of a blank page.
- `ConfigErrorScreen` lists the offending variable names.
- add an explicit allow-list of Stellar network passphrases
(`SUPPORTED_NETWORK_PASSPHRASES`/`isSupportedNetworkPassphrase`).
- fix the `.env.example` wording that claimed all variables are required in
production; docs updated in README/CONTRIBUTING.
#865 - close the PII-filtering gaps and stop logging sensitive URLs
- `stripPii` now: matches Stellar addresses case-insensitively and covers muxed
`M…` addresses, extends the PII key-name list, walks nested objects/arrays so
an email under an unrecognised key is stripped, and drops token-shaped values
(JWT/opaque) so no token reaches a provider.
- `reportUnexpectedError` redacts token-shaped URL segments before logging, so a
password-reset token that travels in the path never reaches the console.
- toast behaviour is opt-out per request (`skipToast`) and set on background
polls/probes, so retries no longer spam the user.
- `RegisterPage` no longer reveals that an email is already registered; a 409 is
made indistinguishable from a successful sign-up, matching `LoginPage`'s
existing anti-enumeration wording.
- update the analytics tests that pinned the old gaps; add nested-PII and
token-payload cases.
---
.env.example | 14 +-
CONTRIBUTING.md | 10 ++
README.md | 44 ++---
src/components/ui/ConfigErrorScreen.tsx | 25 ++-
src/components/ui/ExplorerLink.tsx | 2 +-
src/config/env.ts | 151 ++++++++++++++----
src/config/index.ts | 6 +-
src/config/stellar.ts | 14 +-
src/features/auth/components/AccountMenu.tsx | 14 +-
src/features/auth/components/GuestRoute.tsx | 14 +-
src/features/auth/components/LoginPage.tsx | 11 +-
src/features/auth/components/RegisterPage.tsx | 10 +-
src/features/auth/components/SignInPage.tsx | 21 ++-
src/features/auth/utils.ts | 30 +---
.../orders/OrderConfirmationScreen.tsx | 43 +++--
.../payments/PaymentAssetSelector.tsx | 4 +-
src/features/payments/PaymentResultScreen.tsx | 47 ++++--
.../payments/TransactionStatusPolling.tsx | 13 +-
.../payments/WalletBalanceDisplay.tsx | 11 +-
src/lib/analytics.test.ts | 57 +++++--
src/lib/analytics.ts | 77 +++++++--
src/lib/index.ts | 1 +
src/lib/url.test.ts | 103 ++++++++++++
src/lib/url.ts | 118 ++++++++++++++
src/main.tsx | 13 +-
src/services/http.ts | 17 +-
src/stores/useUiStore.ts | 15 +-
27 files changed, 676 insertions(+), 209 deletions(-)
create mode 100644 src/lib/url.test.ts
create mode 100644 src/lib/url.ts
diff --git a/.env.example b/.env.example
index 4e58c045..52ddf735 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 ed7e0a3f..190787db 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -157,6 +157,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 cc89b319..fd39d62c 100644
--- a/README.md
+++ b/README.md
@@ -89,38 +89,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 7e747213..bfa8e002 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 (