Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,17 @@ SENTRY_PROJECT=
# SENTRY_AUTH_TOKEN: server-only token with `project:releases` scope for
# sourcemap uploads. Never expose this to the client. Leave empty to skip
# sourcemap upload (builds still succeed, traces will be minified).
SENTRY_AUTH_TOKEN=
SENTRY_AUTH_TOKEN=

# CSP violation reporting endpoint (optional)
# Set to your app URL + /api/csp-report to receive browser violation reports
CSP_REPORT_URI=

# Rate limiting whitelist (comma-separated wallet addresses or IPs to bypass)
RATE_LIMIT_WHITELIST=

# Email from address for notifications
EMAIL_FROM=noreply@stellarsplit-dapp.vercel.app

# Resend API key for email notifications (optional - logs to console if not set)
RESEND_API_KEY=
43 changes: 43 additions & 0 deletions docs/CSP_HEADERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Content Security Policy Headers

## Overview
StellarSplit implements a per-request nonce-based CSP to prevent XSS attacks.
A fresh nonce is generated on every request by `src/middleware.ts` and embedded
into `script-src` and `style-src` directives.

## Policy Breakdown

| Directive | Value | Reason |
|-----------|-------|--------|
| `default-src` | `'self'` | Restrict everything to same origin by default |
| `script-src` | `'self' 'nonce-{n}' 'strict-dynamic'` | Nonce for inline scripts, strict-dynamic propagates trust to loaded scripts |
| `style-src` | `'self' 'nonce-{n}' 'unsafe-inline'` | Nonce for inline styles; unsafe-inline needed for Tailwind's CSS-in-JS |
| `img-src` | `'self' data: blob: https://www.gravatar.com` | Allow Gravatar avatars and data URIs |
| `connect-src` | `'self' https://*.stellar.org ...` | Stellar network endpoints + optional analytics |
| `object-src` | `'none'` | Block plugins (Flash, Java) entirely |
| `base-uri` | `'self'` | Prevent base tag hijacking |
| `form-action` | `'self'` | Prevent form submission to external sites |
| `frame-ancestors` | `'none'` (or `*` for /embed routes) | Prevent clickjacking |
| `upgrade-insecure-requests` | — | Rewrite HTTP subresource requests to HTTPS |
| `report-uri` | (optional) | Receive violation reports from browsers |

## Configuration

| Environment Variable | Description |
|---------------------|-------------|
| `CSP_REPORT_URI` | URL to receive violation reports (e.g. `https://your-app.vercel.app/api/csp-report`) |
| `ANALYTICS_ENABLED` | Set to `true` to add `ANALYTICS_ENDPOINT_ORIGINS` to `connect-src` |
| `ANALYTICS_ENDPOINT_ORIGINS` | Comma-separated analytics origins |
| `BACKUP_ALERTS_ENABLED` | Set to `true` to add `BACKUP_WEBHOOK_ORIGINS` to `connect-src` |
| `BACKUP_WEBHOOK_ORIGINS` | Comma-separated backup webhook origins |

## Nonce Flow
1. Middleware generates `nonce = base64(randomUUID())`
2. Nonce is set as `x-nonce` request header for Server Components
3. CSP header is set on the response with the nonce embedded
4. `src/lib/csp.ts:getNonce()` reads it in Server Components via `next/headers`
5. `src/app/layout.tsx` passes the nonce to inline `<script>` and `<style>` tags

## Violation Reporting
Set `CSP_REPORT_URI` to `https://your-app.vercel.app/api/csp-report` to log violations server-side.
The report route at `src/app/api/csp-report/route.ts` logs violations as structured JSON.
81 changes: 81 additions & 0 deletions docs/CSRF_PROTECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# CSRF Protection

## Overview

StellarSplit implements double-submit CSRF protection using HMAC-signed tokens.
All state-changing API requests (POST, PUT, PATCH, DELETE) must carry a valid
`X-CSRF-Token` header or the server returns `403 Forbidden`.

## How It Works

1. The client fetches a token from `GET /api/csrf`
2. The server generates `nonce.expiresAt`, signs it with `HMAC-SHA256` using
`CSRF_SECRET`, and returns `${nonce}.${expiresAt}.${signature}`
3. The token is cached client-side and refreshed 1 minute before expiry (60-minute TTL)
4. Every mutating request automatically includes `X-CSRF-Token: <token>` via
the centralised `apiFetch` wrapper in `src/lib/apiClient.ts`
5. Route handlers call `assertCsrf(request)` which validates the HMAC signature
and checks the expiry timestamp

## Implementation Files

| File | Role |
|------|------|
| `src/lib/csrf.ts` | Token generation (`generateCsrfToken`) and validation (`isValidCsrfToken`) |
| `src/app/api/csrf/route.ts` | `GET /api/csrf` — token issuance endpoint |
| `src/lib/apiClient.ts` | Client-side fetch wrapper — injects header automatically on mutations |
| `src/lib/middleware/csrfMiddleware.ts` | `assertCsrf(request)` guard helper for route handlers |
| `src/hooks/useCsrfToken.ts` | React hook for explicit token access in components |

## Environment Variables

| Variable | Description |
|----------|-------------|
| `CSRF_SECRET` | **Required in production.** A random 32+ byte string used to sign tokens. Generate with: `openssl rand -hex 32` |

> In development (`NODE_ENV !== 'production'`), a fixed insecure fallback secret
> is used automatically so the app works without configuration. A warning is
> logged to the console. **Never rely on the fallback in production.**

## Protecting a New Route

Add CSRF protection to any mutating route handler:

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { assertCsrf } from '@/lib/middleware/csrfMiddleware';

export async function POST(request: NextRequest) {
const csrfError = await assertCsrf(request);
if (csrfError) return csrfError; // 403 if token is missing or invalid

// ... handler logic
}
```

## SameSite Cookies

Next.js sets `SameSite=Lax` on session cookies by default, which prevents
cross-site form submissions in most browsers. The CSRF token provides a
stateless defence layer that remains effective even in environments where
the `SameSite` attribute is not enforced.

## Token Expiry and Refresh

Tokens are valid for **60 minutes**. The client-side hook (`useCsrfToken`)
and the `apiFetch` wrapper both:

1. Refresh the token 60 seconds before it expires
2. Retry once with a freshly-fetched token if the server responds with `403`
(handles the edge case where the token expired in flight)

## Testing

Mutation endpoints return:

```json
{ "error": "Invalid or missing CSRF token" }
```

with HTTP `403` when the `X-CSRF-Token` header is absent, the signature is
invalid, or the token has expired.
39 changes: 39 additions & 0 deletions src/app/api/csp-report/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';

export const dynamic = 'force-dynamic';

/**
* POST /api/csp-report
*
* Receives Content-Security-Policy violation reports from browsers.
* The report body is a JSON object sent by the browser automatically
* when a CSP directive is violated.
*
* Set CSP_REPORT_URI=https://your-app.vercel.app/api/csp-report in production.
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const body = await request.json() as Record<string, unknown>;
const report = (body['csp-report'] ?? body) as Record<string, unknown>;

// Log structured violation for monitoring / alerting.
console.warn('[CSP Violation]', JSON.stringify({
documentUri: report['document-uri'],
violatedDirective: report['violated-directive'],
effectiveDirective: report['effective-directive'],
blockedUri: report['blocked-uri'],
originalPolicy: typeof report['original-policy'] === 'string'
? report['original-policy'].slice(0, 200) + '...'
: undefined,
sourceFile: report['source-file'],
lineNumber: report['line-number'],
columnNumber: report['column-number'],
timestamp: new Date().toISOString(),
}));

return new NextResponse(null, { status: 204 });
} catch {
// Malformed report — ignore silently.
return new NextResponse(null, { status: 204 });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const dynamic = "force-dynamic";

import { splitClient } from "@/lib/stellar";
import { assertCsrf } from "@/lib/middleware/csrfMiddleware";
import { applyRateLimit } from "@/lib/middleware/rateLimitMiddleware";
import { PAYMENT_RATE_LIMIT } from "@/lib/rateLimit";

/**
* In-memory store for manually-marked installment payments.
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/invoices/[id]/payment-confirmed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";

export const dynamic = "force-dynamic";
import type { Invoice, Payment } from "@stellar-split/sdk";
import { applyRateLimit } from "@/lib/middleware/rateLimitMiddleware";
import { PAYMENT_RATE_LIMIT } from "@/lib/rateLimit";
import {

sendPaymentReceiptEmail,
Expand Down Expand Up @@ -30,6 +32,9 @@ export async function POST(
request: NextRequest,
{ params }: { params: { id: string } },
) {
const rateCheck = applyRateLimit(request, PAYMENT_RATE_LIMIT);
if (rateCheck.limited) return rateCheck.response;

try {
const invoiceId = params.id;
const body = await request.json();
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/notifications/mark-read/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCsrf } from '@/lib/middleware/csrfMiddleware';

export async function POST(request: NextRequest) {
const csrfError = await assertCsrf(request);
if (csrfError) return csrfError;
const body = await request.json() as { ids?: string[] };
const ids = Array.isArray(body.ids) ? body.ids.filter((id): id is string => typeof id === 'string') : [];
// In a real backend this would persist to DB; for now acknowledge receipt.
return NextResponse.json({ ok: true, marked: ids.length });
}
105 changes: 105 additions & 0 deletions src/app/api/notifications/send-email/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { assertCsrf } from "@/lib/middleware/csrfMiddleware";

export const dynamic = "force-dynamic";

interface SendEmailBody {
to?: string;
subject?: string;
message?: string;
invoiceId?: string;
}

/**
* POST /api/notifications/send-email
*
* Sends a transactional notification email via Resend.
* Requires a valid CSRF token and a properly formed request body.
*
* When RESEND_API_KEY is not configured the request is acknowledged with
* `skipped: true` so callers can degrade gracefully in dev/staging.
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
const csrfError = await assertCsrf(request);
if (csrfError) return csrfError;

let body: SendEmailBody;
try {
body = (await request.json()) as SendEmailBody;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const { to, subject, message, invoiceId } = body;

if (!to || !subject || !message) {
return NextResponse.json(
{ error: "Missing required fields: to, subject, message" },
{ status: 400 }
);
}

// Basic email format check
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) {
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
}

const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
console.log("[Notifications] RESEND_API_KEY not set — email send skipped:", {
to,
subject,
invoiceId,
});
return NextResponse.json({ ok: true, skipped: true, reason: "Email service not configured" });
}

try {
const fromAddress =
process.env.EMAIL_FROM ?? "noreply@stellarsplit-dapp.vercel.app";
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";

const invoiceLink = invoiceId
? `<p style="margin-top:16px"><a href="${appUrl}/invoice/${invoiceId}" style="color:#6366f1">View Invoice →</a></p>`
: "";

const html = `
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;background:#f9fafb;padding:20px">
<div style="background:#fff;padding:24px;border-radius:8px">
<h2 style="margin:0 0 12px;font-size:20px;color:#111827">StellarSplit Notification</h2>
<p style="margin:0 0 16px;color:#374151;line-height:1.6">${message.replace(/\n/g, "<br/>")}</p>
${invoiceLink}
</div>
<p style="text-align:center;color:#9ca3af;font-size:12px;margin-top:16px">
© ${new Date().getFullYear()} StellarSplit · Automated notification
</p>
</div>
`;

const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: fromAddress,
to: [to],
subject,
html,
}),
});

if (!res.ok) {
const errText = await res.text();
console.error("[Notifications] Resend API error:", errText);
return NextResponse.json({ error: "Email delivery failed" }, { status: 502 });
}

const data = (await res.json()) as { id?: string };
return NextResponse.json({ ok: true, messageId: data.id });
} catch (err) {
console.error("[Notifications] Email send error:", err);
return NextResponse.json({ error: "Internal error sending email" }, { status: 500 });
}
}
17 changes: 17 additions & 0 deletions src/app/api/rate-limit/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { checkRateLimit, getRequestIdentity, PAYMENT_RATE_LIMIT } from '@/lib/rateLimit';

export const dynamic = 'force-dynamic';

/** GET /api/rate-limit/status — returns current rate limit state for the caller. */
export async function GET(request: NextRequest): Promise<NextResponse> {
const identity = getRequestIdentity(request);
// Peek at current state without recording an attempt (pass a copy with maxRequests+1 to avoid side effects).
const result = checkRateLimit(identity, { ...PAYMENT_RATE_LIMIT, maxRequests: PAYMENT_RATE_LIMIT.maxRequests + 1 });
return NextResponse.json({
limit: PAYMENT_RATE_LIMIT.maxRequests,
remaining: Math.min(result.remaining, PAYMENT_RATE_LIMIT.maxRequests),
resetAt: result.resetAt,
windowMs: PAYMENT_RATE_LIMIT.windowMs,
});
}
4 changes: 4 additions & 0 deletions src/app/api/settlement/execute/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { NextRequest, NextResponse } from 'next/server';
import { defaultSettlementEngine } from '@/lib/settlement';
import { applyRateLimit } from '@/lib/middleware/rateLimitMiddleware';
import { PAYMENT_RATE_LIMIT } from '@/lib/rateLimit';

export async function POST(req: NextRequest) {
const rateCheck = applyRateLimit(req, PAYMENT_RATE_LIMIT);
if (rateCheck.limited) return rateCheck.response;
try {
const body = await req.json();
const {
Expand Down
Loading