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 { NextResponse } from 'next/server'
import type { NextRequest } 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 { function envFlag(name: string): boolean {
const raw = process.env[name] const raw = process.env[name]
if (raw === undefined) return false if (raw === undefined) return false
@ -65,13 +79,13 @@ export function middleware(request: NextRequest) {
// API routes: accept session cookie OR API key // API routes: accept session cookie OR API key
if (pathname.startsWith('/api/')) { if (pathname.startsWith('/api/')) {
const apiKey = request.headers.get('x-api-key') 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() return NextResponse.next()
} }
// Backward compat: accept legacy cookie during migration // Backward compat: accept legacy cookie during migration
const legacyCookie = request.cookies.get('mission-control-auth') 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() return NextResponse.next()
} }
@ -85,7 +99,7 @@ export function middleware(request: NextRequest) {
// Backward compat: accept legacy cookie // Backward compat: accept legacy cookie
const legacyCookie = request.cookies.get('mission-control-auth') 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() return NextResponse.next()
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getDatabase, Message } from "@/lib/db" import { getDatabase, Message } from "@/lib/db"
import { requireRole } from '@/lib/auth'
/** /**
* GET /api/agents/comms - Inter-agent communication stats and timeline * GET /api/agents/comms - Inter-agent communication stats and timeline
* Query params: limit, offset, since, agent * Query params: limit, offset, since, agent
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase() const db = getDatabase()
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)

View File

@ -11,6 +11,9 @@ import { getUserFromRequest, requireRole } from '@/lib/auth';
* Query params: status, role, limit, offset * Query params: status, role, limit, offset
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase(); const db = getDatabase();
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);

View File

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

View File

@ -1,9 +1,12 @@
import { NextRequest, NextResponse } from 'next/server' 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 { logAuditEvent } from '@/lib/db'
import { verifyPassword } from '@/lib/password' import { verifyPassword } from '@/lib/password'
export async function GET(request: Request) { 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) const user = getUserFromRequest(request)
if (!user) { if (!user) {

View File

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

View File

@ -1,11 +1,15 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getDatabase } from '@/lib/db' import { getDatabase } from '@/lib/db'
import { requireRole } from '@/lib/auth'
/** /**
* GET /api/chat/conversations - List conversations derived from messages * GET /api/chat/conversations - List conversations derived from messages
* Query params: agent (filter by participant), limit, offset * Query params: agent (filter by participant), limit, offset
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase() const db = getDatabase()
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)

View File

@ -9,6 +9,9 @@ export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const auth = requireRole(request, 'viewer')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try { try {
const db = getDatabase() const db = getDatabase()
const { id } = await params 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 * Query params: conversation_id, from_agent, to_agent, limit, offset, since
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase() const db = getDatabase()
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)

View File

@ -131,6 +131,9 @@ function mapOpenClawJob(job: OpenClawCronJob): CronJob {
} }
export async function GET(request: NextRequest) { 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 { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const action = searchParams.get('action') 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 { eventBus, ServerEvent } from '@/lib/event-bus'
import { requireRole } from '@/lib/auth'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@ -7,7 +9,10 @@ export const runtime = 'nodejs'
* GET /api/events - Server-Sent Events stream for real-time DB mutations. * GET /api/events - Server-Sent Events stream for real-time DB mutations.
* Clients connect via EventSource and receive JSON-encoded events. * 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() const encoder = new TextEncoder()
// Cleanup function, set in start(), called in cancel() // 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) { 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 { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'recent' 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 * GET /api/notifications/deliver - Get delivery status and statistics
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase(); const db = getDatabase();
const { searchParams } = new URL(request.url); 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 * Query params: recipient, unread_only, type, limit, offset
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase(); const db = getDatabase();
const { searchParams } = new URL(request.url); 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 * 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 { try {
const db = getDatabase() const db = getDatabase()
const pipelines = db.prepare( const pipelines = db.prepare(

View File

@ -35,6 +35,9 @@ interface PipelineRun {
* GET /api/pipelines/run - Get pipeline runs * GET /api/pipelines/run - Get pipeline runs
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase() const db = getDatabase()
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)

View File

@ -3,6 +3,9 @@ import { getDatabase, db_helpers } from '@/lib/db'
import { requireRole } from '@/lib/auth' import { requireRole } from '@/lib/auth'
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase() const db = getDatabase()
const { searchParams } = new URL(request.url) 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 { 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 { try {
const gatewaySessions = getAllGatewaySessions() const gatewaySessions = getAllGatewaySessions()

View File

@ -99,6 +99,9 @@ export async function POST(request: NextRequest) {
// Get spawn history // Get spawn history
export async function GET(request: NextRequest) { 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 { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '50') const limit = parseInt(searchParams.get('limit') || '50')

View File

@ -211,6 +211,9 @@ export async function POST(request: NextRequest) {
* Query params: limit, offset * Query params: limit, offset
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase(); const db = getDatabase();
const { searchParams } = new URL(request.url); 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 { config } from '@/lib/config'
import { getDatabase } from '@/lib/db' import { getDatabase } from '@/lib/db'
import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions' import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions'
import { requireRole } from '@/lib/auth'
export async function GET(request: NextRequest) { 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 { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'overview' const action = searchParams.get('action') || 'overview'

View File

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

View File

@ -20,6 +20,9 @@ export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
const auth = requireRole(request, 'viewer');
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
try { try {
const db = getDatabase(); const db = getDatabase();
const resolvedParams = await params; 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 * Query params: status, assigned_to, priority, limit, offset
*/ */
export async function GET(request: NextRequest) { 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 { try {
const db = getDatabase(); const db = getDatabase();
const { searchParams } = new URL(request.url); 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) { 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 { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'list' const action = searchParams.get('action') || 'list'

View File

@ -21,7 +21,10 @@ export interface WorkflowTemplate {
/** /**
* GET /api/workflows - List all workflow templates * 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 { try {
const db = getDatabase() const db = getDatabase()
const templates = db.prepare('SELECT * FROM workflow_templates ORDER BY use_count DESC, updated_at DESC').all() as WorkflowTemplate[] 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>) elements.push(<div key={`${i}-space`} className="mb-2"></div>)
} else if (trimmedLine.length > 0) { } else if (trimmedLine.length > 0) {
if (inList) inList = false if (inList) inList = false
// Handle inline formatting // Handle inline formatting — escape HTML entities first to prevent XSS
let content = trimmedLine let content = trimmedLine
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
// Simple bold formatting // Simple bold formatting
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// Simple italic formatting // Simple italic formatting

View File

@ -1,7 +1,21 @@
import { randomBytes } from 'crypto' import { randomBytes, timingSafeEqual } from 'crypto'
import { getDatabase } from './db' import { getDatabase } from './db'
import { hashPassword, verifyPassword } from './password' 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 { export interface User {
id: number id: number
username: string username: string
@ -202,7 +216,7 @@ export function getUserFromRequest(request: Request): User | null {
// Check API key - return synthetic user // Check API key - return synthetic user
const apiKey = request.headers.get('x-api-key') const apiKey = request.headers.get('x-api-key')
if (apiKey && apiKey === process.env.API_KEY) { if (apiKey && safeCompare(apiKey, process.env.API_KEY || '')) {
return { return {
id: 0, id: 0,
username: 'api', username: 'api',