diff --git a/netlify.toml b/netlify.toml index d15200d..79bd4fb 100644 --- a/netlify.toml +++ b/netlify.toml @@ -10,10 +10,12 @@ [[headers]] for = "/*" [headers.values] + Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://stepfi-api.onrender.com; font-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" X-Frame-Options = "DENY" X-Content-Type-Options = "nosniff" Referrer-Policy = "strict-origin-when-cross-origin" Permissions-Policy = "camera=(), microphone=(), geolocation=()" + Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload" [[headers]] for = "/assets/*" diff --git a/package-lock.json b/package-lock.json index 56ca604..e15be9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.101.0", "axios": "^1.18.0", "clsx": "^2.1.1", + "dompurify": "^3.4.11", "framer-motion": "^12.40.0", "lucide-react": "^1.18.0", "react": "^19.2.6", @@ -22,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/dompurify": "^3.0.5", "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -1036,6 +1038,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1087,6 +1099,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", @@ -1952,6 +1971,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index c0f3b07..d3d94f8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.101.0", "axios": "^1.18.0", "clsx": "^2.1.1", + "dompurify": "^3.4.11", "framer-motion": "^12.40.0", "lucide-react": "^1.18.0", "react": "^19.2.6", @@ -24,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/dompurify": "^3.0.5", "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 4701cd8..296d48f 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -43,8 +43,7 @@ export function Navbar() { setMobileOpen(false)} - > + diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts new file mode 100644 index 0000000..853fd6d --- /dev/null +++ b/src/lib/sanitize.ts @@ -0,0 +1,11 @@ +import DOMPurify from 'dompurify' + +DOMPurify.setConfig({ ALLOWED_TAGS: [], ALLOWED_ATTR: [] }) + +export function sanitizeText(input: string): string { + return DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] }) +} + +export function sanitizeHtml(input: string): string { + return DOMPurify.sanitize(input) +} diff --git a/src/pages/LearnerProfile.tsx b/src/pages/LearnerProfile.tsx index e8e6049..b20b956 100644 --- a/src/pages/LearnerProfile.tsx +++ b/src/pages/LearnerProfile.tsx @@ -10,6 +10,7 @@ import { Card } from '../components/ui/Card' import { Button } from '../components/ui/Button' import { Badge } from '../components/ui/Badge' import { Spinner } from '../components/ui/Spinner' +import { sanitizeText } from '../lib/sanitize' import type { LearnerProfile, ReputationHistoryPoint, Vouch, Loan } from '../types' const TIER_COLORS: Record = { @@ -272,7 +273,7 @@ export function LearnerProfile() { {vouch.message && ( -

{vouch.message}

+

{sanitizeText(vouch.message)}

)}

{new Date(vouch.createdAt).toLocaleDateString()} diff --git a/src/pages/VendorDashboard.tsx b/src/pages/VendorDashboard.tsx index fdc3120..582140a 100644 --- a/src/pages/VendorDashboard.tsx +++ b/src/pages/VendorDashboard.tsx @@ -11,6 +11,7 @@ import { Card } from '../components/ui/Card' import { Button } from '../components/ui/Button' import { Badge } from '../components/ui/Badge' import { Spinner } from '../components/ui/Spinner' +import { sanitizeText } from '../lib/sanitize' import type { VendorDashboardOverview, VendorLoan, VendorPayment, VendorProduct, ApiKey, PaginatedResponse @@ -142,7 +143,7 @@ function LoansTable({ {data.map((loan) => ( {loan.id.slice(0, 8)}... - {loan.product} + {sanitizeText(loan.product)} {loan.borrower.slice(0, 6)}...{loan.borrower.slice(-4)} @@ -217,11 +218,11 @@ function ProductsSection({ products, isLoading }: { products?: VendorProduct[]; className="p-4 rounded-xl border border-border bg-elevated/30" >

-

{product.name}

+

{sanitizeText(product.name)}

{product.description && ( -

{product.description}

+

{sanitizeText(product.description)}

)}

${product.price.toLocaleString()}

@@ -341,7 +342,7 @@ function ApiKeySection({ >
- {key.label} + {sanitizeText(key.label)}

diff --git a/src/services/api.ts b/src/services/api.ts index 8993a30..1d0937a 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -8,10 +8,25 @@ export const api = axios.create({ }, }) +const TOKEN_STORAGE_KEY = 'stepfi-user' + +function getStoredTokens(): { accessToken?: string; refreshToken?: string } { + try { + const raw = localStorage.getItem(TOKEN_STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + const state = parsed?.state + if (!state) return {} + return { accessToken: state.accessToken, refreshToken: state.refreshToken } + } catch { + return {} + } +} + api.interceptors.request.use((config) => { - const token = localStorage.getItem('accessToken') - if (token) { - config.headers.Authorization = `Bearer ${token}` + const { accessToken } = getStoredTokens() + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}` } return config }) @@ -20,8 +35,7 @@ api.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { - localStorage.removeItem('accessToken') - localStorage.removeItem('refreshToken') + localStorage.removeItem(TOKEN_STORAGE_KEY) window.location.href = '/' } return Promise.reject(error) diff --git a/src/stores/user.store.ts b/src/stores/user.store.ts index 85fabc9..14f1f9c 100644 --- a/src/stores/user.store.ts +++ b/src/stores/user.store.ts @@ -16,13 +16,9 @@ export const useUserStore = create()( refreshToken: '', isAuthenticated: false, setTokens: (accessToken, refreshToken) => { - localStorage.setItem('accessToken', accessToken) - localStorage.setItem('refreshToken', refreshToken) set({ accessToken, refreshToken, isAuthenticated: true }) }, clearTokens: () => { - localStorage.removeItem('accessToken') - localStorage.removeItem('refreshToken') set({ accessToken: '', refreshToken: '', diff --git a/vercel.json b/vercel.json index 170b3f6..af092bd 100644 --- a/vercel.json +++ b/vercel.json @@ -2,5 +2,45 @@ "framework": "vite", "buildCommand": "npm run build", "outputDirectory": "dist", - "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://stepfi-api.onrender.com; font-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=()" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000; includeSubDomains; preload" + } + ] + }, + { + "source": "/assets/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + } + ] }