Merge pull request #21 from builderz-labs/fix/p0-security-critical

fix: P0 security critical — auth guards, timing-safe compare, XSS
This commit is contained in:
nyk 2026-02-27 13:56:50 +07:00 committed by GitHub
commit 98f1990b57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 146 additions and 19 deletions

View File

@ -1,6 +1,20 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
/** Edge-compatible constant-time string comparison. */
function safeCompare(a: string, b: string): boolean {
if (typeof a !== 'string' || typeof b !== 'string') return false
const encoder = new TextEncoder()
const bufA = encoder.encode(a)
const bufB = encoder.encode(b)
if (bufA.length !== bufB.length) return false
let result = 0
for (let i = 0; i < bufA.length; i++) {
result |= bufA[i] ^ bufB[i]
}
return result === 0
}
function envFlag(name: string): boolean {
const raw = process.env[name]
if (raw === undefined) return false
@ -65,13 +79,13 @@ export function middleware(request: NextRequest) {
// API routes: accept session cookie OR API key
if (pathname.startsWith('/api/')) {
const apiKey = request.headers.get('x-api-key')
if (sessionToken || (apiKey && apiKey === process.env.API_KEY)) {
if (sessionToken || (apiKey && safeCompare(apiKey, process.env.API_KEY || ''))) {
return NextResponse.next()
}
// Backward compat: accept legacy cookie during migration
const legacyCookie = request.cookies.get('mission-control-auth')
if (legacyCookie?.value === process.env.AUTH_SECRET) {
if (safeCompare(legacyCookie?.value || '', process.env.AUTH_SECRET || '')) {
return NextResponse.next()
}
@ -85,7 +99,7 @@ export function middleware(request: NextRequest) {
// Backward compat: accept legacy cookie
const legacyCookie = request.cookies.get('mission-control-auth')
if (legacyCookie?.value === process.env.AUTH_SECRET) {
if (safeCompare(legacyCookie?.value || '', process.env.AUTH_SECRET || '')) {
return NextResponse.next()
}

View File

@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase, Activity } from '@/lib/db';
import { requireRole } from '@/lib/auth'
/**
* GET /api/activities - Get activity stream or stats
* Query params: type, actor, entity_type, limit, offset, since, hours (for stats)
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { searchParams, pathname } = new URL(request.url);

View File

@ -16,6 +16,9 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase();
const resolvedParams = await params;

View File

@ -11,6 +11,9 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const { id } = await params

View File

@ -13,6 +13,9 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase();
const resolvedParams = await params;

View File

@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from "next/server"
import { getDatabase, Message } from "@/lib/db"
import { requireRole } from '@/lib/auth'
/**
* GET /api/agents/comms - Inter-agent communication stats and timeline
* Query params: limit, offset, since, agent
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)

View File

@ -11,6 +11,9 @@ import { getUserFromRequest, requireRole } from '@/lib/auth';
* Query params: status, role, limit, offset
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);

View File

@ -1,6 +1,6 @@
import { randomBytes } from 'crypto'
import { NextRequest, NextResponse } from 'next/server'
import { createUser, getUserFromRequest } from '@/lib/auth'
import { createUser, getUserFromRequest , requireRole } from '@/lib/auth'
import { getDatabase, logAuditEvent } from '@/lib/db'
function makeUsernameFromEmail(email: string): string {
@ -20,6 +20,9 @@ function ensureUniqueUsername(base: string): string {
}
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const user = getUserFromRequest(request)
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })

View File

@ -1,9 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
import { getUserFromRequest, updateUser } from '@/lib/auth'
import { getUserFromRequest, updateUser , requireRole } from '@/lib/auth'
import { logAuditEvent } from '@/lib/db'
import { verifyPassword } from '@/lib/password'
export async function GET(request: Request) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const user = getUserFromRequest(request)
if (!user) {

View File

@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser } from '@/lib/auth'
import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser , requireRole } from '@/lib/auth'
import { logAuditEvent } from '@/lib/db'
/**
* GET /api/auth/users - List all users (admin only)
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const user = getUserFromRequest(request)
if (!user || user.role !== 'admin') {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })

View File

@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDatabase } from '@/lib/db'
import { requireRole } from '@/lib/auth'
/**
* GET /api/chat/conversations - List conversations derived from messages
* Query params: agent (filter by participant), limit, offset
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)

View File

@ -9,6 +9,9 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const { id } = await params

View File

@ -96,6 +96,9 @@ function extractReplyText(waitPayload: any): string | null {
* Query params: conversation_id, from_agent, to_agent, limit, offset, since
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)

View File

@ -131,6 +131,9 @@ function mapOpenClawJob(job: OpenClawCronJob): CronJob {
}
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action')

View File

@ -1,4 +1,6 @@
import { NextRequest , NextResponse } from 'next/server'
import { eventBus, ServerEvent } from '@/lib/event-bus'
import { requireRole } from '@/lib/auth'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@ -7,7 +9,10 @@ export const runtime = 'nodejs'
* GET /api/events - Server-Sent Events stream for real-time DB mutations.
* Clients connect via EventSource and receive JSON-encoded events.
*/
export async function GET() {
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const encoder = new TextEncoder()
// Cleanup function, set in start(), called in cancel()

View File

@ -175,6 +175,9 @@ async function readLogFile(filePath: string, source: string, maxLines: number):
}
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'recent'

View File

@ -182,6 +182,9 @@ export async function POST(request: NextRequest) {
* GET /api/notifications/deliver - Get delivery status and statistics
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);

View File

@ -7,6 +7,9 @@ import { requireRole } from '@/lib/auth';
* Query params: recipient, unread_only, type, limit, offset
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);

View File

@ -23,7 +23,10 @@ export interface Pipeline {
/**
* GET /api/pipelines - List all pipelines with enriched step data
*/
export async function GET() {
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const pipelines = db.prepare(

View File

@ -35,6 +35,9 @@ interface PipelineRun {
* GET /api/pipelines/run - Get pipeline runs
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)

View File

@ -3,6 +3,9 @@ import { getDatabase, db_helpers } from '@/lib/db'
import { requireRole } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const { searchParams } = new URL(request.url)

View File

@ -1,7 +1,11 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { getAllGatewaySessions } from '@/lib/sessions'
import { requireRole } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
export async function GET() {
try {
const gatewaySessions = getAllGatewaySessions()

View File

@ -99,6 +99,9 @@ export async function POST(request: NextRequest) {
// Get spawn history
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '50')

View File

@ -211,6 +211,9 @@ export async function POST(request: NextRequest) {
* Query params: limit, offset
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);

View File

@ -5,8 +5,12 @@ import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
import { config } from '@/lib/config'
import { getDatabase } from '@/lib/db'
import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions'
import { requireRole } from '@/lib/auth'
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'overview'

View File

@ -9,6 +9,9 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'viewer');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const db = getDatabase();
const resolvedParams = await params;

View File

@ -20,6 +20,9 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireRole(request, 'viewer');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const db = getDatabase();
const resolvedParams = await params;

View File

@ -18,6 +18,9 @@ function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number): b
* Query params: status, assigned_to, priority, limit, offset
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const db = getDatabase();
const { searchParams } = new URL(request.url);

View File

@ -170,6 +170,9 @@ function filterByTimeframe(records: TokenUsageRecord[], timeframe: string): Toke
}
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'list'

View File

@ -21,7 +21,10 @@ export interface WorkflowTemplate {
/**
* GET /api/workflows - List all workflow templates
*/
export async function GET() {
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const db = getDatabase()
const templates = db.prepare('SELECT * FROM workflow_templates ORDER BY use_count DESC, updated_at DESC').all() as WorkflowTemplate[]

View File

@ -321,8 +321,13 @@ export function MemoryBrowserPanel() {
elements.push(<div key={`${i}-space`} className="mb-2"></div>)
} else if (trimmedLine.length > 0) {
if (inList) inList = false
// Handle inline formatting
// Handle inline formatting — escape HTML entities first to prevent XSS
let content = trimmedLine
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
// Simple bold formatting
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// Simple italic formatting

View File

@ -1,7 +1,21 @@
import { randomBytes } from 'crypto'
import { randomBytes, timingSafeEqual } from 'crypto'
import { getDatabase } from './db'
import { hashPassword, verifyPassword } from './password'
/**
* Constant-time string comparison to prevent timing attacks.
*/
export function safeCompare(a: string, b: string): boolean {
if (typeof a !== 'string' || typeof b !== 'string') return false
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) {
timingSafeEqual(bufA, bufA)
return false
}
return timingSafeEqual(bufA, bufB)
}
export interface User {
id: number
username: string
@ -202,7 +216,7 @@ export function getUserFromRequest(request: Request): User | null {
// Check API key - return synthetic user
const apiKey = request.headers.get('x-api-key')
if (apiKey && apiKey === process.env.API_KEY) {
if (apiKey && safeCompare(apiKey, process.env.API_KEY || '')) {
return {
id: 0,
username: 'api',