Skip to content

Commit 765a3d7

Browse files
committed
fix: harden frontend CSP, auth token revocation, focus trap, and API base URL config
Move the CSP connect-src directive from next.config.mjs into middleware so it is evaluated at request time instead of being baked in at build time, revoke the JWT server-side on profile deletion instead of only clearing local storage, trap focus inside the session-expired dialog for keyboard users, and standardize the frontend's default API base URL/port across config, page files, and env examples. Closes #865 Closes #864 Closes #863 Closes #858
1 parent 0ae5ca0 commit 765a3d7

8 files changed

Lines changed: 91 additions & 65 deletions

File tree

frontend/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ NEXT_PUBLIC_HORIZON_URL=https://horizon-testnet.stellar.org
33
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
44
NEXT_PUBLIC_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
55
NEXT_PUBLIC_SUPABASE_URL=https://crnsvgljoopphjnmcawu.supabase.co
6-
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000
6+
NEXT_PUBLIC_API_BASE_URL=http://localhost:4001
77
NEXT_PUBLIC_CONTRACT_ID=CDSIKFT4Z5UDWJ6BU5R7F7O6G6NCVXZ6GTNTPWFFOW6PHA2APK3PRR6U
88
NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

frontend/next.config.mjs

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,12 @@ const nextConfig = {
3232
return [
3333
{
3434
// Deny framing on all non-embed routes to mitigate clickjacking
35+
// Content-Security-Policy is set in middleware.ts instead of here:
36+
// process.env is only resolved at build time in next.config.mjs,
37+
// which bakes in an empty connect-src on hosts where these vars
38+
// are injected at runtime rather than build time.
3539
source: "/((?!embed).*)",
36-
headers: [
37-
{ key: "X-Frame-Options", value: "DENY" },
38-
{
39-
key: "Content-Security-Policy",
40-
value: [
41-
"default-src 'self'",
42-
"script-src 'self' 'unsafe-inline'", // 'unsafe-inline' needed for Next.js inline chunks
43-
"style-src 'self' 'unsafe-inline'",
44-
`connect-src 'self' ${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""} ${process.env.NEXT_PUBLIC_HORIZON_URL ?? ""} ${process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? ""}`,
45-
"img-src 'self' data: blob: https:",
46-
"frame-ancestors 'none'",
47-
].join("; "),
48-
},
49-
],
40+
headers: [{ key: "X-Frame-Options", value: "DENY" }],
5041
},
5142
{
5243
// Allow cross-origin embedding of the embed widget pages

frontend/src/app/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Link from "next/link";
22
import Image from "next/image";
33
import { AppShell } from "@/components/app-shell";
44
import { ProfileCard } from "@/components/profile-card";
5+
import { API_BASE_URL } from "@/lib/config";
56

67
// ── Types ──────────────────────────────────────────────────────────────
78
type Asset = { code: string; issuer?: string | null };
@@ -23,10 +24,8 @@ type Profile = {
2324
// ── Data fetching (server component) ──────────────────────────────────
2425
async function getFeaturedProfiles(): Promise<Profile[]> {
2526
try {
26-
const apiUrl =
27-
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
2827
const res = await fetch(
29-
`${apiUrl}/v1/profiles?limit=3&sort=most_supported`,
28+
`${API_BASE_URL}/v1/profiles?limit=3&sort=most_supported`,
3029
{ next: { revalidate: 300 } },
3130
);
3231
if (!res.ok) return [];

frontend/src/app/settings/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ export default function SettingsPage() {
6060
try {
6161
const res = await apiFetch(`${API_BASE_URL}/profiles/${username}`, { method: "DELETE" });
6262
if (!res.ok) throw new Error("Failed to delete profile");
63+
64+
// Revoke the JWT server-side so it can't keep authenticating requests
65+
// as the now-deleted profile for the rest of its 1h expiry.
66+
await apiFetch(`${API_BASE_URL}/v1/auth/logout`, { method: "POST" }).catch(() => {});
67+
6368
localStorage.removeItem("username");
6469
router.push("/");
6570
} catch (err: unknown) {

frontend/src/components/auth-expired-listener.tsx

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useEffect, useState } from "react";
44
import { useRouter } from "next/navigation";
5+
import FocusTrap from "focus-trap-react";
56

67
// #826: apiFetch dispatches "auth:expired" instead of forcing a full page
78
// reload on 401, so in-progress form state elsewhere on the page survives.
@@ -20,57 +21,59 @@ export function AuthExpiredListener() {
2021
if (!expired) return null;
2122

2223
return (
23-
<div
24-
role="alertdialog"
25-
aria-modal="true"
26-
aria-labelledby="auth-expired-title"
27-
style={{
28-
position: "fixed",
29-
inset: 0,
30-
zIndex: 100,
31-
display: "flex",
32-
alignItems: "center",
33-
justifyContent: "center",
34-
background: "rgba(0, 0, 0, 0.6)",
35-
}}
36-
>
24+
<FocusTrap focusTrapOptions={{ escapeDeactivates: false }}>
3725
<div
26+
role="alertdialog"
27+
aria-modal="true"
28+
aria-labelledby="auth-expired-title"
3829
style={{
39-
maxWidth: 360,
40-
borderRadius: 16,
41-
padding: 24,
42-
background: "#0a0a0f",
43-
border: "1px solid rgba(255,255,255,0.1)",
44-
color: "#fff",
30+
position: "fixed",
31+
inset: 0,
32+
zIndex: 100,
33+
display: "flex",
34+
alignItems: "center",
35+
justifyContent: "center",
36+
background: "rgba(0, 0, 0, 0.6)",
4537
}}
4638
>
47-
<h2 id="auth-expired-title" style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
48-
Session expired
49-
</h2>
50-
<p style={{ marginTop: 8, marginBottom: 16, fontSize: 14, color: "rgba(255,255,255,0.6)" }}>
51-
Your session has expired. Any unsaved changes on this page were not submitted.
52-
Please log in again to continue.
53-
</p>
54-
<button
55-
type="button"
56-
onClick={() => {
57-
setExpired(false);
58-
router.replace("/");
59-
}}
39+
<div
6040
style={{
61-
width: "100%",
62-
padding: "10px 16px",
63-
borderRadius: 9999,
64-
border: "none",
65-
background: "#00e5b0",
66-
color: "#0a0a0f",
67-
fontWeight: 600,
68-
cursor: "pointer",
41+
maxWidth: 360,
42+
borderRadius: 16,
43+
padding: 24,
44+
background: "#0a0a0f",
45+
border: "1px solid rgba(255,255,255,0.1)",
46+
color: "#fff",
6947
}}
7048
>
71-
Log in again
72-
</button>
49+
<h2 id="auth-expired-title" style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
50+
Session expired
51+
</h2>
52+
<p style={{ marginTop: 8, marginBottom: 16, fontSize: 14, color: "rgba(255,255,255,0.6)" }}>
53+
Your session has expired. Any unsaved changes on this page were not submitted.
54+
Please log in again to continue.
55+
</p>
56+
<button
57+
type="button"
58+
onClick={() => {
59+
setExpired(false);
60+
router.replace("/");
61+
}}
62+
style={{
63+
width: "100%",
64+
padding: "10px 16px",
65+
borderRadius: 9999,
66+
border: "none",
67+
background: "#00e5b0",
68+
color: "#0a0a0f",
69+
fontWeight: 600,
70+
cursor: "pointer",
71+
}}
72+
>
73+
Log in again
74+
</button>
75+
</div>
7376
</div>
74-
</div>
77+
</FocusTrap>
7578
);
7679
}

frontend/src/components/profile-card.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useState, useCallback, KeyboardEvent } from "react";
33
import Image from "next/image";
44
import { isValidStellarAddress, stellarExpertUrl } from "@/lib/stellar";
55
import { useToast } from "@/lib/use-toast";
6-
import { SITE_URL } from "@/lib/config";
6+
import { API_BASE_URL, SITE_URL } from "@/lib/config";
77
import { apiFetch } from "@/lib/api-client";
88

99
import { ProfileCardSkeleton } from "./skeleton";
@@ -64,7 +64,7 @@ export function ProfileCard({
6464
const handleResend = async () => {
6565
setResending(true);
6666
try {
67-
const res = await apiFetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'}/profiles/${username}/resend-verification-email`, {
67+
const res = await apiFetch(`${API_BASE_URL}/profiles/${username}/resend-verification-email`, {
6868
method: "POST",
6969
});
7070
const data = await res.json();

frontend/src/lib/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const DEFAULTS: Record<string, string> = {
22
NEXT_PUBLIC_HORIZON_URL: "https://horizon-testnet.stellar.org",
3-
NEXT_PUBLIC_API_BASE_URL: "http://localhost:3001",
3+
NEXT_PUBLIC_API_BASE_URL: "http://localhost:4001",
44
};
55

66
function requireEnv(key: string): string {

frontend/src/middleware.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { NextResponse } from "next/server";
2+
import type { NextRequest } from "next/server";
3+
4+
// Built here (at request time) rather than in next.config.mjs's headers(),
5+
// since process.env there is only resolved at build time and would bake in
6+
// an empty connect-src on hosts where these vars are injected at runtime.
7+
export function middleware(request: NextRequest) {
8+
const response = NextResponse.next();
9+
10+
const csp = [
11+
"default-src 'self'",
12+
"script-src 'self' 'unsafe-inline'",
13+
"style-src 'self' 'unsafe-inline'",
14+
`connect-src 'self' ${process.env.NEXT_PUBLIC_API_BASE_URL ?? ""} ${process.env.NEXT_PUBLIC_HORIZON_URL ?? ""} ${process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? ""}`,
15+
"img-src 'self' data: blob: https:",
16+
"frame-ancestors 'none'",
17+
].join("; ");
18+
19+
response.headers.set("Content-Security-Policy", csp);
20+
21+
return response;
22+
}
23+
24+
export const config = {
25+
// Excludes /embed/*, which keeps its own permissive frame-ancestors CSP
26+
// set in next.config.mjs, plus static assets.
27+
matcher: "/((?!embed|_next/static|_next/image|favicon.ico).*)",
28+
};

0 commit comments

Comments
 (0)