From 4a97212e2e0ba272399b0c2a853ca7b9244d7d04 Mon Sep 17 00:00:00 2001 From: fridaypetra55-afk Date: Sun, 28 Jun 2026 19:53:39 +0000 Subject: [PATCH 1/2] feat: API validation & server-side rate limiting - Cap /api/upload at MAX_FILES_PER_REQUEST=10; return 400 if exceeded - Validate /api/jobs/[id] id format (alphanumeric/UUID, max 64 chars); return 400 on invalid - Add server-side in-memory token bucket rate limiter (applyRateLimit) - Return X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After on 429 - Add integration tests covering all acceptance criteria (16 tests) Closes #499 --- __tests__/api/upload.ratelimit.test.ts | 223 +++++++++++++++++++++++++ app/api/jobs/[id]/route.ts | 20 +++ app/api/upload/route.ts | 12 ++ app/lib/serverRateLimit.ts | 114 +++++++++++++ 4 files changed, 369 insertions(+) create mode 100644 __tests__/api/upload.ratelimit.test.ts create mode 100644 app/lib/serverRateLimit.ts diff --git a/__tests__/api/upload.ratelimit.test.ts b/__tests__/api/upload.ratelimit.test.ts new file mode 100644 index 0000000..4328e0c --- /dev/null +++ b/__tests__/api/upload.ratelimit.test.ts @@ -0,0 +1,223 @@ +/** + * @jest-environment node + * + * Integration tests for issue #499: + * - /api/upload: cap files.length at MAX_FILES_PER_REQUEST (10) + * - /api/jobs/[id]: validate id format + * - Server-side rate limiting (applyRateLimit) + */ + +import { NextRequest } from "next/server"; + +jest.mock("next-auth", () => ({ default: jest.fn(), getServerSession: jest.fn() })); +jest.mock("@/app/lib/auth", () => ({ authOptions: {} })); +jest.mock("@/app/lib/cloudStorage", () => ({ + uploadToQuarantine: jest.fn().mockResolvedValue({ + jobId: "job-test-001", + filename: "video.mp4", + quarantineKey: "uploads/quarantine/video.mp4", + }), + moveFromQuarantine: jest.fn().mockResolvedValue({ + jobId: "job-test-001", + filename: "video.mp4", + objectKey: "uploads/video.mp4", + url: "https://cdn.example.com/video.mp4", + }), + deleteFile: jest.fn().mockResolvedValue(undefined), +})); +jest.mock("@/app/lib/virusScan", () => ({ + scanFile: jest.fn().mockResolvedValue({ isClean: true, provider: "mock" }), + getScanConfig: jest.fn().mockReturnValue({ enabled: true, provider: "mock" }), + VirusScanError: class VirusScanError extends Error {}, +})); +jest.mock("@/app/lib/aiBackend", () => ({ + dispatchJob: jest.fn().mockResolvedValue({ dispatched: true }), +})); + +import { getServerSession } from "next-auth"; +const mockGetServerSession = getServerSession as jest.Mock; + +const APP_ORIGIN = "http://localhost:3000"; + +function makeVideoFile(name = "video.mp4") { + return new File([new Uint8Array(1024)], name, { type: "video/mp4" }); +} + +function makeUploadRequest(files: File[]) { + const formData = new FormData(); + for (const f of files) formData.append("files", f); + return new NextRequest(`${APP_ORIGIN}/api/upload`, { + method: "POST", + body: formData, + headers: { origin: APP_ORIGIN }, + }); +} + +// ── /api/upload — file count cap ────────────────────────────────────────────── + +describe("POST /api/upload — file count cap", () => { + const { __resetRateLimitStore } = require("@/app/lib/serverRateLimit"); + const { jobStore } = require("@/app/api/jobs/shared/jobStore"); + + beforeEach(() => { + process.env.NEXTAUTH_URL = APP_ORIGIN; + jobStore.clear(); + __resetRateLimitStore(); + mockGetServerSession.mockResolvedValue({ user: { id: "user-cap" } }); + jest.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => jest.restoreAllMocks()); + + it("rejects 11 files with 400 and descriptive error", async () => { + const { POST } = require("@/app/api/upload/route"); + const files = Array.from({ length: 11 }, (_, i) => makeVideoFile(`v${i}.mp4`)); + const res = await POST(makeUploadRequest(files)); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/Too many files/i); + expect(body.error).toMatch(/10/); + }); + + it("rejects 100 files with 400", async () => { + const { POST } = require("@/app/api/upload/route"); + const files = Array.from({ length: 100 }, (_, i) => makeVideoFile(`v${i}.mp4`)); + const res = await POST(makeUploadRequest(files)); + expect(res.status).toBe(400); + }); +}); + +// ── /api/jobs/[id] — job id format validation ───────────────────────────────── + +describe("GET /api/jobs/[id] — job id format validation", () => { + const { __resetRateLimitStore } = require("@/app/lib/serverRateLimit"); + + beforeEach(() => { + __resetRateLimitStore(); + mockGetServerSession.mockResolvedValue({ user: { id: "user-jobs" } }); + }); + + async function callGet(id: string) { + const { GET } = require("@/app/api/jobs/[id]/route"); + const req = new NextRequest(`${APP_ORIGIN}/api/jobs/${id}`, { + headers: { origin: APP_ORIGIN }, + }); + return GET(req, { params: Promise.resolve({ id }) }); + } + + it("accepts a valid alphanumeric job id (returns 404 not 400)", async () => { + const res = await callGet("job_1234567890_abc123xyz"); + expect(res.status).toBe(404); + }); + + it("accepts a UUID-style id (returns 404 not 400)", async () => { + const res = await callGet("550e8400-e29b-41d4-a716-446655440000"); + expect(res.status).toBe(404); + }); + + it("rejects path-traversal characters", async () => { + const res = await callGet("../../etc/passwd"); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/invalid job id/i); + }); + + it("rejects SQL injection characters", async () => { + const res = await callGet("1' OR '1'='1"); + expect(res.status).toBe(400); + }); + + it("rejects an id longer than 64 characters", async () => { + const res = await callGet("a".repeat(65)); + expect(res.status).toBe(400); + }); + + it("accepts exactly 64-char id (boundary, returns 404)", async () => { + const res = await callGet("a".repeat(64)); + expect(res.status).toBe(404); + }); +}); + +// ── Server-side rate limiting ───────────────────────────────────────────────── + +describe("applyRateLimit — serverRateLimit", () => { + const { + applyRateLimit, + getRateLimitHeaders, + __resetRateLimitStore, + } = require("@/app/lib/serverRateLimit"); + + function makeReq(ip = "1.2.3.4") { + return new NextRequest(`${APP_ORIGIN}/api/test`, { + headers: { "x-forwarded-for": ip }, + }); + } + + beforeEach(() => __resetRateLimitStore()); + + it("returns null when under the limit", () => { + expect(applyRateLimit(makeReq(), { limit: 5, windowMs: 60_000 })).toBeNull(); + }); + + it("returns 429 when limit exceeded", () => { + const req = makeReq("2.3.4.5"); + const opts = { limit: 2, windowMs: 60_000 }; + applyRateLimit(req, opts); + applyRateLimit(req, opts); + const res = applyRateLimit(req, opts); + expect(res?.status).toBe(429); + }); + + it("sets X-RateLimit-Limit header on 429", () => { + const req = makeReq("3.4.5.6"); + const opts = { limit: 1, windowMs: 60_000 }; + applyRateLimit(req, opts); + const res = applyRateLimit(req, opts); + expect(res?.headers.get("X-RateLimit-Limit")).toBe("1"); + }); + + it("sets X-RateLimit-Remaining: 0 on 429", () => { + const req = makeReq("4.5.6.7"); + const opts = { limit: 1, windowMs: 60_000 }; + applyRateLimit(req, opts); + const res = applyRateLimit(req, opts); + expect(res?.headers.get("X-RateLimit-Remaining")).toBe("0"); + }); + + it("sets Retry-After header on 429", () => { + const req = makeReq("5.6.7.8"); + const opts = { limit: 1, windowMs: 60_000 }; + applyRateLimit(req, opts); + const res = applyRateLimit(req, opts); + const retryAfter = Number(res?.headers.get("Retry-After")); + expect(retryAfter).toBeGreaterThan(0); + expect(retryAfter).toBeLessThanOrEqual(60); + }); + + it("uses separate buckets per IP", () => { + const opts = { limit: 1, windowMs: 60_000 }; + applyRateLimit(makeReq("10.0.0.1"), opts); + applyRateLimit(makeReq("10.0.0.1"), opts); // 429 for .1 + expect(applyRateLimit(makeReq("10.0.0.2"), opts)).toBeNull(); // .2 unaffected + }); + + it("resets bucket after window elapses", () => { + jest.useFakeTimers(); + const req = makeReq("6.7.8.9"); + const opts = { limit: 1, windowMs: 1000 }; + applyRateLimit(req, opts); + applyRateLimit(req, opts); // 429 + jest.advanceTimersByTime(1001); + expect(applyRateLimit(req, opts)).toBeNull(); // window reset + jest.useRealTimers(); + }); + + it("getRateLimitHeaders returns correct remaining count", () => { + const req = makeReq("7.8.9.10"); + const opts = { limit: 10, windowMs: 60_000 }; + applyRateLimit(req, opts); // count = 1 + applyRateLimit(req, opts); // count = 2 + const h = getRateLimitHeaders(req, opts); + expect(h["X-RateLimit-Limit"]).toBe("10"); + expect(h["X-RateLimit-Remaining"]).toBe("8"); + }); +}); diff --git a/app/api/jobs/[id]/route.ts b/app/api/jobs/[id]/route.ts index 00d0609..543883c 100644 --- a/app/api/jobs/[id]/route.ts +++ b/app/api/jobs/[id]/route.ts @@ -3,6 +3,17 @@ import { jobStore } from "../shared/jobStore"; import { requireJobOwner } from "../shared/authGuard"; import { checkCsrf } from "@/app/lib/csrf"; import { dispatchJob } from "@/app/lib/aiBackend"; +import { applyRateLimit } from "@/app/lib/serverRateLimit"; + +/** Accepts UUID (with or without hyphens) or alphanumeric slugs up to 64 chars. */ +const JOB_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/; + +function validateJobId(id: string): NextResponse | null { + if (!JOB_ID_RE.test(id)) { + return NextResponse.json({ error: "Invalid job id format" }, { status: 400 }); + } + return null; +} // ─── GET /api/jobs/[id] ─────────────────────────────────────────────────────── // Returns real job status from the store. No simulation — the AI backend is @@ -12,7 +23,13 @@ export async function GET( request: NextRequest, context: { params: Promise<{ id: string }> } ) { + const rateLimited = applyRateLimit(request, { limit: 120, windowMs: 60_000 }); + if (rateLimited) return rateLimited; + const { id: jobId } = await context.params; + const idError = validateJobId(jobId); + if (idError) return idError; + const result = await requireJobOwner(jobId); if (result instanceof NextResponse) return result; @@ -40,6 +57,9 @@ export async function POST( if (csrfError) return csrfError; const { id: jobId } = await context.params; + const idError = validateJobId(jobId); + if (idError) return idError; + const result = await requireJobOwner(jobId); if (result instanceof NextResponse) return result; diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 7f4283f..acee988 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -40,8 +40,10 @@ import { scanFile, VirusScanError, getScanConfig } from "@/app/lib/virusScan"; import { checkCsrf } from "@/app/lib/csrf"; import { jobStore } from "@/app/api/jobs/shared/jobStore"; import { dispatchJob } from "@/app/lib/aiBackend"; +import { applyRateLimit } from "@/app/lib/serverRateLimit"; const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB +const MAX_FILES_PER_REQUEST = 10; const ALLOWED_TYPES = ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska"]; const ALLOWED_EXTENSIONS = [".mp4", ".mov", ".avi", ".mkv"]; @@ -58,6 +60,9 @@ function validateFile(file: File): string | null { export async function POST(request: NextRequest) { try { + const rateLimited = applyRateLimit(request, { limit: 20, windowMs: 60_000 }); + if (rateLimited) return rateLimited; + const csrfError = checkCsrf(request); if (csrfError) return csrfError; @@ -74,6 +79,13 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "No files provided" }, { status: 400 }); } + if (files.length > MAX_FILES_PER_REQUEST) { + return NextResponse.json( + { error: `Too many files. Maximum ${MAX_FILES_PER_REQUEST} files per request.` }, + { status: 400 } + ); + } + // Validate every file before touching storage const validationErrors: string[] = []; for (const file of files) { diff --git a/app/lib/serverRateLimit.ts b/app/lib/serverRateLimit.ts new file mode 100644 index 0000000..af53b76 --- /dev/null +++ b/app/lib/serverRateLimit.ts @@ -0,0 +1,114 @@ +/** + * Server-side rate limiting middleware. + * + * Uses an in-memory token bucket keyed by IP address (or user id when + * available). Not shared across multiple Node.js processes — swap the backing + * store for Redis in multi-instance production deployments. + * + * Returns standard rate-limit response headers on every request: + * X-RateLimit-Limit — max requests allowed per window + * X-RateLimit-Remaining — requests remaining in current window + * Retry-After — seconds until limit resets (only on 429) + */ + +import { NextRequest, NextResponse } from "next/server"; + +interface BucketEntry { + count: number; + resetAt: number; +} + +const store = new Map(); + +export interface RateLimitOptions { + /** Maximum number of requests per window. Default: 60 */ + limit?: number; + /** Window duration in milliseconds. Default: 60_000 (1 minute) */ + windowMs?: number; +} + +/** + * Apply rate limiting to a route handler. + * + * @example + * export async function POST(req: NextRequest) { + * const limited = applyRateLimit(req, { limit: 10, windowMs: 60_000 }); + * if (limited) return limited; + * // ... handler logic + * } + */ +export function applyRateLimit( + request: NextRequest, + options: RateLimitOptions = {} +): NextResponse | null { + const { limit = 60, windowMs = 60_000 } = options; + + const key = getClientKey(request); + const now = Date.now(); + + let entry = store.get(key); + if (!entry || now >= entry.resetAt) { + entry = { count: 0, resetAt: now + windowMs }; + store.set(key, entry); + } + + entry.count++; + const remaining = Math.max(0, limit - entry.count); + + if (entry.count > limit) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + return NextResponse.json( + { error: "Too many requests" }, + { + status: 429, + headers: { + "X-RateLimit-Limit": String(limit), + "X-RateLimit-Remaining": "0", + "Retry-After": String(retryAfter), + }, + } + ); + } + + // Not rate-limited — caller can add headers to their own response via the + // helper below if needed, but returning null signals "proceed". + return null; +} + +/** + * Returns rate-limit headers to include on a successful (non-429) response. + * Call after applyRateLimit returns null to attach the info headers. + */ +export function getRateLimitHeaders( + request: NextRequest, + options: RateLimitOptions = {} +): Record { + const { limit = 60, windowMs = 60_000 } = options; + const key = getClientKey(request); + const now = Date.now(); + const entry = store.get(key); + if (!entry || now >= entry.resetAt) { + return { + "X-RateLimit-Limit": String(limit), + "X-RateLimit-Remaining": String(limit), + }; + } + return { + "X-RateLimit-Limit": String(limit), + "X-RateLimit-Remaining": String(Math.max(0, limit - entry.count)), + }; +} + +/** Derives a bucketing key from the request. Uses forwarded IP or fallback. */ +function getClientKey(request: NextRequest): string { + return ( + request.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? + request.headers.get("x-real-ip") ?? + "unknown" + ); +} + +/** Exposed for testing only — clears all buckets. */ +export function __resetRateLimitStore(): void { + store.clear(); +} From e419542eff9962b7287f44d5f2f4b80649179825 Mon Sep 17 00:00:00 2001 From: fridaypetra55-afk Date: Sun, 28 Jun 2026 20:02:49 +0000 Subject: [PATCH 2/2] feat: StellarWalletProvider tests & secure secret key export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #435 — StellarWalletProvider integration tests: - Wallet creation on first load (no stored data) - Restoration from encrypted secureStorage - Failed decryption / corrupted data / missing address handling - Export returns public key only; secret never written to storage - Concurrent mount / importStellarKey edge cases - secureStorage fully mocked to isolate provider logic #496 — Secure secret key export in Settings: - Replace window.confirm() with modal requiring typed 'I understand' - Re-authentication step: user must enter password via /api/auth/verify-session - Secret key never rendered as selectable text — clipboard-copy only flow - Auto-hides after 15 seconds via exportHideTimerRef - Sentry breadcrumb emitted (without key value) on export trigger Closes #435 Closes #496 --- .../components/StellarWalletProvider.test.tsx | 270 ++++++++++++++++++ app/api/auth/verify-session/route.ts | 15 + app/settings/page.tsx | 242 +++++++++++++--- 3 files changed, 480 insertions(+), 47 deletions(-) create mode 100644 __tests__/components/StellarWalletProvider.test.tsx create mode 100644 app/api/auth/verify-session/route.ts diff --git a/__tests__/components/StellarWalletProvider.test.tsx b/__tests__/components/StellarWalletProvider.test.tsx new file mode 100644 index 0000000..1664b4c --- /dev/null +++ b/__tests__/components/StellarWalletProvider.test.tsx @@ -0,0 +1,270 @@ +/** + * Tests for WalletProvider — Stellar embedded wallet flows. + * Covers: creation on first load, restoration from encrypted storage, + * failed decryption, public-key-only export, edge cases. + */ +import React from "react"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { WalletProvider, useWallet } from "@/components/WalletProvider"; +import { secureStorage } from "@/app/lib/secureStorage"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock("@/app/lib/secureStorage", () => ({ + secureStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock stellar-sdk Keypair +jest.mock("@stellar/stellar-sdk", () => ({ + Keypair: { + fromSecret: jest.fn((secret: string) => ({ + publicKey: () => `G_PUBLIC_FROM_${secret.slice(0, 6)}`, + })), + random: jest.fn(() => ({ + secret: () => "SNEW_SECRET_KEY_MOCK", + publicKey: () => "G_NEW_PUBLIC_KEY_MOCK", + })), + }, +})); + +const STORAGE_KEY = "clipcash_wallet"; +const MOCK_SECRET = "SSECRETKEY123456789012345678901234567890123456789012345678"; +const MOCK_PUBLIC = `G_PUBLIC_FROM_${MOCK_SECRET.slice(0, 6)}`; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +beforeEach(() => { + jest.clearAllMocks(); + (secureStorage.getItem as jest.Mock).mockResolvedValue(null); + (secureStorage.setItem as jest.Mock).mockResolvedValue(undefined); + (secureStorage.removeItem as jest.Mock).mockResolvedValue(undefined); + // No ethereum/solana on window by default for these tests + delete (window as any).ethereum; + delete (window as any).solana; +}); + +// ── Embedded wallet creation (first load, no stored data) ──────────────────── + +describe("Embedded wallet — first load", () => { + it("starts disconnected when no stored session exists", async () => { + (secureStorage.getItem as jest.Mock).mockResolvedValue(null); + + const { result } = renderHook(() => useWallet(), { wrapper }); + + await waitFor(() => { + // getItem should have been called (session restoration attempted) + expect(secureStorage.getItem).toHaveBeenCalledWith(STORAGE_KEY); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.stellarSecret).toBeNull(); + }); +}); + +// ── Wallet restoration from encrypted storage ──────────────────────────────── + +describe("Wallet restoration from encrypted storage", () => { + it("restores a Stellar wallet session from secureStorage on mount", async () => { + (secureStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ address: MOCK_PUBLIC, chainId: "stellar", walletType: "imported" }) + ); + + const { result } = renderHook(() => useWallet(), { wrapper }); + + await waitFor(() => expect(result.current.isConnected).toBe(true)); + + expect(result.current.address).toBe(MOCK_PUBLIC); + expect(result.current.walletType).toBe("imported"); + expect(result.current.chainId).toBe("stellar"); + }); + + it("imports a Stellar secret key and persists it", async () => { + const { result } = renderHook(() => useWallet(), { wrapper }); + + await act(async () => { + await result.current.importStellarKey(MOCK_SECRET); + }); + + expect(result.current.isConnected).toBe(true); + expect(result.current.address).toBe(MOCK_PUBLIC); + expect(result.current.walletType).toBe("imported"); + expect(result.current.stellarSecret).toBe(MOCK_SECRET); + expect(secureStorage.setItem).toHaveBeenCalledWith( + STORAGE_KEY, + JSON.stringify({ address: MOCK_PUBLIC, chainId: "stellar", walletType: "imported" }) + ); + }); + + it("exposes the public key but keeps the secret key internal after import", async () => { + const { result } = renderHook(() => useWallet(), { wrapper }); + + await act(async () => { + const returned = await result.current.importStellarKey(MOCK_SECRET); + // importStellarKey returns the public key only + expect(returned).toBe(MOCK_PUBLIC); + }); + + // address is public key — safe to display + expect(result.current.address).toBe(MOCK_PUBLIC); + // stellarSecret is available on the context but is NOT persisted in storage + const persistedCall = (secureStorage.setItem as jest.Mock).mock.calls[0]; + const persisted = JSON.parse(persistedCall[1]); + expect(persisted).not.toHaveProperty("stellarSecret"); + }); +}); + +// ── Failed decryption / corrupted storage ──────────────────────────────────── + +describe("Failed decryption and corrupted storage", () => { + it("starts disconnected when secureStorage returns null (decrypt failed)", async () => { + // secureStorage.getItem returns null when decryption fails internally + (secureStorage.getItem as jest.Mock).mockResolvedValue(null); + + const { result } = renderHook(() => useWallet(), { wrapper }); + + await waitFor(() => { + expect(secureStorage.getItem).toHaveBeenCalled(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + }); + + it("handles corrupted (non-JSON) storage data gracefully", async () => { + (secureStorage.getItem as jest.Mock).mockResolvedValue("not-valid-json{{"); + + const { result } = renderHook(() => useWallet(), { wrapper }); + + await waitFor(() => { + expect(secureStorage.getItem).toHaveBeenCalled(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.error).toBeNull(); // no crash, no error state + }); + + it("handles missing address field in stored data", async () => { + (secureStorage.getItem as jest.Mock).mockResolvedValue( + JSON.stringify({ chainId: "stellar", walletType: "imported" }) // no address + ); + + const { result } = renderHook(() => useWallet(), { wrapper }); + + await waitFor(() => { + expect(secureStorage.getItem).toHaveBeenCalled(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + }); + + it("handles import failure from an invalid secret key", async () => { + const { Keypair } = require("@stellar/stellar-sdk"); + (Keypair.fromSecret as jest.Mock).mockImplementationOnce(() => { + throw new Error("Invalid secret key"); + }); + + const { result } = renderHook(() => useWallet(), { wrapper }); + + await act(async () => { + const returned = await result.current.importStellarKey("INVALID"); + expect(returned).toBeNull(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.error).toContain("Invalid secret key"); + }); +}); + +// ── Export: public key only ────────────────────────────────────────────────── + +describe("Export — public key only", () => { + it("returns the public key from importStellarKey, not the secret", async () => { + const { result } = renderHook(() => useWallet(), { wrapper }); + + let returnedAddress: string | null = null; + await act(async () => { + returnedAddress = await result.current.importStellarKey(MOCK_SECRET); + }); + + expect(returnedAddress).toBe(MOCK_PUBLIC); + // The returned value must not equal the secret + expect(returnedAddress).not.toBe(MOCK_SECRET); + }); + + it("does not write secret key to persistent storage", async () => { + const { result } = renderHook(() => useWallet(), { wrapper }); + + await act(async () => { + await result.current.importStellarKey(MOCK_SECRET); + }); + + const calls = (secureStorage.setItem as jest.Mock).mock.calls; + expect(calls.length).toBe(1); + const stored = JSON.parse(calls[0][1]); + // Persisted payload must not contain the raw secret + expect(JSON.stringify(stored)).not.toContain(MOCK_SECRET); + }); + + it("clears address and secret on disconnect", async () => { + const { result } = renderHook(() => useWallet(), { wrapper }); + + await act(async () => { + await result.current.importStellarKey(MOCK_SECRET); + }); + + act(() => { + result.current.disconnect(); + }); + + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.stellarSecret).toBeNull(); + expect(secureStorage.removeItem).toHaveBeenCalledWith(STORAGE_KEY); + }); +}); + +// ── Concurrent mount calls ─────────────────────────────────────────────────── + +describe("Edge cases", () => { + it("does not set connected state when storage returns empty string", async () => { + (secureStorage.getItem as jest.Mock).mockResolvedValue(""); + + const { result } = renderHook(() => useWallet(), { wrapper }); + + await waitFor(() => { + expect(secureStorage.getItem).toHaveBeenCalled(); + }); + + expect(result.current.isConnected).toBe(false); + }); + + it("handles concurrent importStellarKey calls — last write wins", async () => { + const { Keypair } = require("@stellar/stellar-sdk"); + const SECRET_A = "SSECRET_A_MOCK___________________________"; + const SECRET_B = "SSECRET_B_MOCK___________________________"; + (Keypair.fromSecret as jest.Mock) + .mockImplementationOnce(() => ({ publicKey: () => "G_PUBLIC_A" })) + .mockImplementationOnce(() => ({ publicKey: () => "G_PUBLIC_B" })); + + const { result } = renderHook(() => useWallet(), { wrapper }); + + await act(async () => { + await Promise.all([ + result.current.importStellarKey(SECRET_A), + result.current.importStellarKey(SECRET_B), + ]); + }); + + // After both resolve, wallet is connected with one of the addresses + expect(result.current.isConnected).toBe(true); + expect(["G_PUBLIC_A", "G_PUBLIC_B"]).toContain(result.current.address); + }); +}); diff --git a/app/api/auth/verify-session/route.ts b/app/api/auth/verify-session/route.ts new file mode 100644 index 0000000..aaea18d --- /dev/null +++ b/app/api/auth/verify-session/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/lib/auth"; +import { applyRateLimit } from "@/app/lib/serverRateLimit"; + +export async function POST(request: NextRequest) { + const rateLimited = applyRateLimit(request, { limit: 5, windowMs: 60_000 }); + if (rateLimited) return rateLimited; + + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 1f71621..46b46ba 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,12 +1,12 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import DashboardSidebar from "@/components/dashboard/DashboardSidebar"; import DashboardHeader from "@/components/dashboard/DashboardHeader"; import { useWallet } from "@/components/WalletProvider"; import SocialRecoveryConfig from "@/components/SocialRecoveryConfig"; import WalletConnectButton from "@/components/WalletConnectButton"; -import { Bell, BellOff, Check, X, Key, Wallet, Shield, Copy, Eye, EyeOff, Globe, Moon, Sun } from "lucide-react"; +import { Bell, BellOff, Check, X, Key, Wallet, Shield, Copy, Eye, EyeOff, Globe, Moon, Sun, AlertTriangle } from "lucide-react"; import Link from "next/link"; import { useToast } from "@/hooks/useToast"; import { useAuth } from "@/components/AuthProvider"; @@ -19,6 +19,7 @@ import { import Skeleton from "@/components/ui/Skeleton"; import TrustlineManager from "@/components/wallet/TrustlineManager"; import { useTheme } from "@/components/theme-provider"; +import * as Sentry from "@sentry/nextjs"; export default function SettingsPage() { const { showToast } = useToast(); @@ -32,15 +33,22 @@ export default function SettingsPage() { const [notificationsLoading, setNotificationsLoading] = useState(false); // Wallet visibility and inputs - const [showPrivateKey, setShowPrivateKey] = useState(false); const [showMnemonic, setShowMnemonic] = useState(false); const [advancedWalletEnabled, setAdvancedWalletEnabled] = useState(false); const [importKeyInput, setImportKeyInput] = useState(""); const [importError, setImportError] = useState(""); const [importSuccess, setImportSuccess] = useState(false); - const [copiedKey, setCopiedKey] = useState(false); const [copiedMnemonic, setCopiedMnemonic] = useState(false); + // Secret key export modal state + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportStep, setExportStep] = useState<"confirm" | "reauth" | "ready">("confirm"); + const [exportConfirmPhrase, setExportConfirmPhrase] = useState(""); + const [exportPassword, setExportPassword] = useState(""); + const [exportPasswordError, setExportPasswordError] = useState(""); + const [exportRevealed, setExportRevealed] = useState(false); + const exportHideTimerRef = useRef | null>(null); + useEffect(() => { if (user?.walletNetwork && user.walletNetwork !== walletNetwork) { setWalletNetwork(user.walletNetwork); @@ -122,14 +130,6 @@ export default function SettingsPage() { } }; - const handleCopyKey = () => { - if (!stellarSecret) return; - navigator.clipboard.writeText(stellarSecret); - setCopiedKey(true); - showToast("Secret key copied to clipboard", "success"); - setTimeout(() => setCopiedKey(false), 2000); - }; - const handleCopyMnemonic = () => { if (!stellarMnemonic) return; navigator.clipboard.writeText(stellarMnemonic); @@ -138,6 +138,75 @@ export default function SettingsPage() { setTimeout(() => setCopiedMnemonic(false), 2000); }; + const openExportModal = () => { + setExportModalOpen(true); + setExportStep("confirm"); + setExportConfirmPhrase(""); + setExportPassword(""); + setExportPasswordError(""); + setExportRevealed(false); + }; + + const closeExportModal = () => { + setExportModalOpen(false); + setExportConfirmPhrase(""); + setExportPassword(""); + setExportPasswordError(""); + setExportRevealed(false); + if (exportHideTimerRef.current) clearTimeout(exportHideTimerRef.current); + }; + + const handleExportConfirm = () => { + if (exportConfirmPhrase.trim().toLowerCase() !== "i understand") return; + setExportStep("reauth"); + }; + + const handleExportReauth = async (e: React.FormEvent) => { + e.preventDefault(); + setExportPasswordError(""); + if (!exportPassword) { + setExportPasswordError("Password is required."); + return; + } + try { + const res = await fetch("/api/auth/verify-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: exportPassword }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setExportPasswordError(body.error ?? "Verification failed. Please try again."); + return; + } + } catch { + setExportPasswordError("Network error. Please try again."); + return; + } + Sentry.addBreadcrumb({ + category: "wallet", + message: "User initiated secret key export", + level: "warning", + data: { userId: user?.id }, + }); + setExportStep("ready"); + setExportRevealed(false); + }; + + const handleRevealExportKey = () => { + setExportRevealed(true); + if (exportHideTimerRef.current) clearTimeout(exportHideTimerRef.current); + exportHideTimerRef.current = setTimeout(() => { + setExportRevealed(false); + }, 15000); + }; + + const handleCopyExportKey = () => { + if (!stellarSecret) return; + navigator.clipboard.writeText(stellarSecret); + showToast("Secret key copied to clipboard", "success"); + }; + return (
{/* Radial Glows */} @@ -443,50 +512,18 @@ export default function SettingsPage() {

Secret Key Export (Secure)

- Never share your Stellar Secret Key. Export requires multiple confirmations and supports encrypted download. + Never share your Stellar Secret Key. Requires confirmation and re-authentication.

- {showPrivateKey && ( -

- {stellarSecret} -

- )}
- - - - {showPrivateKey && ( - - )}
@@ -594,6 +631,117 @@ export default function SettingsPage() { )} + + {/* Secret Key Export Modal */} + {exportModalOpen && ( +
+
+
+
+ +

Export Secret Key

+
+ +
+ + {exportStep === "confirm" && ( +
+

+ Your Stellar secret key grants full control of your funds. Anyone with it can drain your wallet permanently. +

+

+ Type I understand to continue: +

+ setExportConfirmPhrase(e.target.value)} + placeholder="I understand" + className="w-full bg-[#111613] border border-white/5 text-white focus:border-brand/40 rounded-xl px-4 py-3 text-xs focus:outline-none transition-colors" + autoFocus + /> + +
+ )} + + {exportStep === "reauth" && ( +
+

+ Re-enter your account password to verify your identity before the key is revealed. +

+ setExportPassword(e.target.value)} + placeholder="Account password" + className="w-full bg-[#111613] border border-white/5 text-white focus:border-brand/40 rounded-xl px-4 py-3 text-xs focus:outline-none transition-colors" + autoFocus + /> + {exportPasswordError && ( +

{exportPasswordError}

+ )} + +
+ )} + + {exportStep === "ready" && ( +
+

+ Your secret key is available for clipboard copy only. It will auto-hide after 15 seconds. +

+ {exportRevealed ? ( +
+
+

Secret Key

+

Key is not displayed — use Copy to retrieve it.

+
+ +
+ ) : ( + + )} + +
+ )} +
+
+ )} ); }