feat(security): rate-limit /api/flows/start, CSRF double-submit cookie, CSP headers
- Rate-limit /api/flows/start to 10 req/min per user (in-memory, matches login pattern) - Add middleware.ts: validates x-csrf-token header against csrf_token cookie on all API POST requests; issues the cookie on GET if missing; sets CSP, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy on all responses - Add lib/csrf.ts: client-side apiFetch() wrapper that injects the CSRF header - Update all client components (login, useFlowRun, docker, caddy, git, systemd) to use apiFetch() for POST requests - Cookie config in login route already correct (NODE_ENV check, httpOnly, sameSite=strict) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1e31e3b584
commit
aa1fd41bec
11 changed files with 108 additions and 8 deletions
|
|
@ -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_URL = process.env.OPS_AGENT_URL ?? 'http://127.0.0.1:3099'
|
||||||
const AGENT_SECRET = process.env.OPS_AGENT_SECRET ?? ''
|
const AGENT_SECRET = process.env.OPS_AGENT_SECRET ?? ''
|
||||||
|
|
||||||
|
const flowStartAttempts = new Map<string, number[]>()
|
||||||
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Response.json({ error: 'unauthorized' }, { status: 401 })
|
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 }
|
let body: { command_key?: string; args?: string[]; stdin?: string }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { parseCertList, type CertInfo } from '@/lib/parse-caddy'
|
import { parseCertList, type CertInfo } from '@/lib/parse-caddy'
|
||||||
|
import { apiFetch } from '@/lib/csrf'
|
||||||
|
|
||||||
async function fetchCerts(): Promise<CertInfo[]> {
|
async function fetchCerts(): Promise<CertInfo[]> {
|
||||||
const res = await fetch('/api/agent/exec', {
|
const res = await apiFetch('/api/agent/exec', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ command_key: 'caddy_list_certs', args: [] }),
|
body: JSON.stringify({ command_key: 'caddy_list_certs', args: [] }),
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import { type Container, parseDockerPs } from '@/lib/parse-docker'
|
||||||
import { useFlowRun } from '@/hooks/useFlowRun'
|
import { useFlowRun } from '@/hooks/useFlowRun'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog'
|
import ConfirmDialog from '@/components/ConfirmDialog'
|
||||||
import StreamingTerminal from '@/components/StreamingTerminal'
|
import StreamingTerminal from '@/components/StreamingTerminal'
|
||||||
|
import { apiFetch } from '@/lib/csrf'
|
||||||
|
|
||||||
async function fetchContainers(): Promise<Container[]> {
|
async function fetchContainers(): Promise<Container[]> {
|
||||||
const res = await fetch('/api/agent/exec', {
|
const res = await apiFetch('/api/agent/exec', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ command_key: 'docker_ps' }),
|
body: JSON.stringify({ command_key: 'docker_ps' }),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { apiFetch } from '@/lib/csrf'
|
||||||
|
|
||||||
async function fetchDiff(repoPath: string): Promise<string> {
|
async function fetchDiff(repoPath: string): Promise<string> {
|
||||||
const res = await fetch('/api/agent/exec', {
|
const res = await apiFetch('/api/agent/exec', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ command_key: 'git_diff', args: [repoPath] }),
|
body: JSON.stringify({ command_key: 'git_diff', args: [repoPath] }),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { type RepoStatus, parseGitStatus } from '@/lib/parse-git'
|
||||||
import { useFlowRun } from '@/hooks/useFlowRun'
|
import { useFlowRun } from '@/hooks/useFlowRun'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog'
|
import ConfirmDialog from '@/components/ConfirmDialog'
|
||||||
import StreamingTerminal from '@/components/StreamingTerminal'
|
import StreamingTerminal from '@/components/StreamingTerminal'
|
||||||
|
import { apiFetch } from '@/lib/csrf'
|
||||||
|
|
||||||
interface RepoEntry {
|
interface RepoEntry {
|
||||||
path: string
|
path: string
|
||||||
|
|
@ -15,7 +16,7 @@ interface RepoEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRepoStatus(repoPath: string): Promise<RepoStatus> {
|
async function fetchRepoStatus(repoPath: string): Promise<RepoStatus> {
|
||||||
const res = await fetch('/api/agent/exec', {
|
const res = await apiFetch('/api/agent/exec', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ command_key: 'git_status', args: [repoPath] }),
|
body: JSON.stringify({ command_key: 'git_status', args: [repoPath] }),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { apiFetch } from '@/lib/csrf'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -17,7 +18,7 @@ export default function LoginPage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await apiFetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/parse-systemd'
|
import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/parse-systemd'
|
||||||
|
import { apiFetch } from '@/lib/csrf'
|
||||||
|
|
||||||
async function fetchOutput(commandKey: string, args: string[]): Promise<string> {
|
async function fetchOutput(commandKey: string, args: string[]): Promise<string> {
|
||||||
const res = await fetch('/api/agent/exec', {
|
const res = await apiFetch('/api/agent/exec', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ command_key: commandKey, args }),
|
body: JSON.stringify({ command_key: commandKey, args }),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/p
|
||||||
import { useFlowRun } from '@/hooks/useFlowRun'
|
import { useFlowRun } from '@/hooks/useFlowRun'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog'
|
import ConfirmDialog from '@/components/ConfirmDialog'
|
||||||
import StreamingTerminal from '@/components/StreamingTerminal'
|
import StreamingTerminal from '@/components/StreamingTerminal'
|
||||||
|
import { apiFetch } from '@/lib/csrf'
|
||||||
|
|
||||||
interface UnitEntry {
|
interface UnitEntry {
|
||||||
unit: string
|
unit: string
|
||||||
|
|
@ -14,7 +15,7 @@ interface UnitEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUnitStatus(unit: string): Promise<UnitStatus> {
|
async function fetchUnitStatus(unit: string): Promise<UnitStatus> {
|
||||||
const res = await fetch('/api/agent/exec', {
|
const res = await apiFetch('/api/agent/exec', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ command_key: 'systemctl_status', args: [unit] }),
|
body: JSON.stringify({ command_key: 'systemctl_status', args: [unit] }),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import type { TerminalLine, TerminalStatus } from '@/components/StreamingTerminal'
|
import type { TerminalLine, TerminalStatus } from '@/components/StreamingTerminal'
|
||||||
|
import { apiFetch } from '@/lib/csrf'
|
||||||
|
|
||||||
export interface FlowRunState {
|
export interface FlowRunState {
|
||||||
status: TerminalStatus
|
status: TerminalStatus
|
||||||
|
|
@ -26,7 +27,7 @@ export function useFlowRun(onComplete?: (flowRunId: string, exitCode: number | n
|
||||||
async (url: string, body: Record<string, unknown>, signal: AbortSignal) => {
|
async (url: string, body: Record<string, unknown>, signal: AbortSignal) => {
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await apiFetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|
|
||||||
21
lib/csrf.ts
Normal file
21
lib/csrf.ts
Normal file
|
|
@ -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<Response> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
55
middleware.ts
Normal file
55
middleware.ts
Normal file
|
|
@ -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).*)'],
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue