diff --git a/app/api/flows/start/route.ts b/app/api/flows/start/route.ts index a2e6796..f757484 100644 --- a/app/api/flows/start/route.ts +++ b/app/api/flows/start/route.ts @@ -8,12 +8,28 @@ export const dynamic = 'force-dynamic' const AGENT_URL = process.env.OPS_AGENT_URL ?? 'http://127.0.0.1:3099' const AGENT_SECRET = process.env.OPS_AGENT_SECRET ?? '' +const flowStartAttempts = new Map() +const MAX_FLOW_ATTEMPTS = 10 +const FLOW_WINDOW_MS = 60_000 + +function isFlowRateLimited(userId: string): boolean { + const now = Date.now() + const attempts = (flowStartAttempts.get(userId) ?? []).filter((t) => now - t < FLOW_WINDOW_MS) + attempts.push(now) + flowStartAttempts.set(userId, attempts) + return attempts.length > MAX_FLOW_ATTEMPTS +} + export async function POST(request: NextRequest) { const user = await getCurrentUser() if (!user) { return Response.json({ error: 'unauthorized' }, { status: 401 }) } + if (isFlowRateLimited(user.id)) { + return Response.json({ error: 'Too many requests' }, { status: 429 }) + } + let body: { command_key?: string; args?: string[]; stdin?: string } try { body = await request.json() diff --git a/app/caddy/_components/caddy-view.tsx b/app/caddy/_components/caddy-view.tsx index 62f7bd4..883c7db 100644 --- a/app/caddy/_components/caddy-view.tsx +++ b/app/caddy/_components/caddy-view.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react' import { parseCertList, type CertInfo } from '@/lib/parse-caddy' +import { apiFetch } from '@/lib/csrf' async function fetchCerts(): Promise { - const res = await fetch('/api/agent/exec', { + const res = await apiFetch('/api/agent/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command_key: 'caddy_list_certs', args: [] }), diff --git a/app/docker/_components/docker-table.tsx b/app/docker/_components/docker-table.tsx index 4e0cac0..937d860 100644 --- a/app/docker/_components/docker-table.tsx +++ b/app/docker/_components/docker-table.tsx @@ -6,9 +6,10 @@ import { type Container, parseDockerPs } from '@/lib/parse-docker' import { useFlowRun } from '@/hooks/useFlowRun' import ConfirmDialog from '@/components/ConfirmDialog' import StreamingTerminal from '@/components/StreamingTerminal' +import { apiFetch } from '@/lib/csrf' async function fetchContainers(): Promise { - const res = await fetch('/api/agent/exec', { + const res = await apiFetch('/api/agent/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command_key: 'docker_ps' }), diff --git a/app/git/[repo]/_components/diff-viewer.tsx b/app/git/[repo]/_components/diff-viewer.tsx index ca06d1a..067eda6 100644 --- a/app/git/[repo]/_components/diff-viewer.tsx +++ b/app/git/[repo]/_components/diff-viewer.tsx @@ -1,9 +1,10 @@ 'use client' import { useCallback, useEffect, useState } from 'react' +import { apiFetch } from '@/lib/csrf' async function fetchDiff(repoPath: string): Promise { - const res = await fetch('/api/agent/exec', { + const res = await apiFetch('/api/agent/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command_key: 'git_diff', args: [repoPath] }), diff --git a/app/git/_components/git-repos-list.tsx b/app/git/_components/git-repos-list.tsx index 275a004..7cffa4e 100644 --- a/app/git/_components/git-repos-list.tsx +++ b/app/git/_components/git-repos-list.tsx @@ -6,6 +6,7 @@ import { type RepoStatus, parseGitStatus } from '@/lib/parse-git' import { useFlowRun } from '@/hooks/useFlowRun' import ConfirmDialog from '@/components/ConfirmDialog' import StreamingTerminal from '@/components/StreamingTerminal' +import { apiFetch } from '@/lib/csrf' interface RepoEntry { path: string @@ -15,7 +16,7 @@ interface RepoEntry { } async function fetchRepoStatus(repoPath: string): Promise { - const res = await fetch('/api/agent/exec', { + const res = await apiFetch('/api/agent/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command_key: 'git_status', args: [repoPath] }), diff --git a/app/login/page.tsx b/app/login/page.tsx index 1ebb739..68efb3a 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' +import { apiFetch } from '@/lib/csrf' export default function LoginPage() { const router = useRouter() @@ -17,7 +18,7 @@ export default function LoginPage() { setLoading(true) try { - const res = await fetch('/api/auth/login', { + const res = await apiFetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), diff --git a/app/systemd/[unit]/_components/unit-detail.tsx b/app/systemd/[unit]/_components/unit-detail.tsx index 4e54da2..3532976 100644 --- a/app/systemd/[unit]/_components/unit-detail.tsx +++ b/app/systemd/[unit]/_components/unit-detail.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react' import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/parse-systemd' +import { apiFetch } from '@/lib/csrf' async function fetchOutput(commandKey: string, args: string[]): Promise { - const res = await fetch('/api/agent/exec', { + const res = await apiFetch('/api/agent/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command_key: commandKey, args }), diff --git a/app/systemd/_components/systemd-units-list.tsx b/app/systemd/_components/systemd-units-list.tsx index bb9a84d..f15b5b2 100644 --- a/app/systemd/_components/systemd-units-list.tsx +++ b/app/systemd/_components/systemd-units-list.tsx @@ -6,6 +6,7 @@ import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/p import { useFlowRun } from '@/hooks/useFlowRun' import ConfirmDialog from '@/components/ConfirmDialog' import StreamingTerminal from '@/components/StreamingTerminal' +import { apiFetch } from '@/lib/csrf' interface UnitEntry { unit: string @@ -14,7 +15,7 @@ interface UnitEntry { } async function fetchUnitStatus(unit: string): Promise { - const res = await fetch('/api/agent/exec', { + const res = await apiFetch('/api/agent/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command_key: 'systemctl_status', args: [unit] }), diff --git a/hooks/useFlowRun.ts b/hooks/useFlowRun.ts index 238fba3..517c42a 100644 --- a/hooks/useFlowRun.ts +++ b/hooks/useFlowRun.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useRef } from 'react' import type { TerminalLine, TerminalStatus } from '@/components/StreamingTerminal' +import { apiFetch } from '@/lib/csrf' export interface FlowRunState { status: TerminalStatus @@ -26,7 +27,7 @@ export function useFlowRun(onComplete?: (flowRunId: string, exitCode: number | n async (url: string, body: Record, signal: AbortSignal) => { let response: Response try { - response = await fetch(url, { + response = await apiFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/lib/csrf.ts b/lib/csrf.ts new file mode 100644 index 0000000..cc23a82 --- /dev/null +++ b/lib/csrf.ts @@ -0,0 +1,21 @@ +'use client' + +function getCsrfToken(): string { + if (typeof document === 'undefined') return '' + return ( + document.cookie + .split('; ') + .find((c) => c.startsWith('csrf_token=')) + ?.split('=')[1] ?? '' + ) +} + +/** Drop-in replacement for fetch() that automatically injects the CSRF token on POST requests. */ +export function apiFetch(url: string, init: RequestInit = {}): Promise { + if ((init.method ?? 'GET').toUpperCase() !== 'POST') { + return fetch(url, init) + } + const headers = new Headers(init.headers) + headers.set('x-csrf-token', getCsrfToken()) + return fetch(url, { ...init, headers }) +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..99f0bae --- /dev/null +++ b/middleware.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' + +const CSP = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self'", + "img-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; ') + +const CSRF_COOKIE = 'csrf_token' +const CSRF_HEADER = 'x-csrf-token' + +export function middleware(request: NextRequest) { + const { method, nextUrl } = request + + // Validate CSRF token on all POST requests to API routes + if (method === 'POST' && nextUrl.pathname.startsWith('/api/')) { + const cookieToken = request.cookies.get(CSRF_COOKIE)?.value + const headerToken = request.headers.get(CSRF_HEADER) + if (!cookieToken || cookieToken !== headerToken) { + return new NextResponse( + JSON.stringify({ error: 'CSRF validation failed' }), + { status: 403, headers: { 'Content-Type': 'application/json' } }, + ) + } + } + + const response = NextResponse.next() + + response.headers.set('Content-Security-Policy', CSP) + response.headers.set('X-Frame-Options', 'DENY') + response.headers.set('X-Content-Type-Options', 'nosniff') + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin') + + // Issue a CSRF token cookie on GET requests when not yet present + if (method === 'GET' && !request.cookies.get(CSRF_COOKIE)) { + response.cookies.set(CSRF_COOKIE, crypto.randomUUID(), { + httpOnly: false, // must be readable by client JS for the double-submit pattern + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + path: '/', + }) + } + + return response +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +}