[object Object]

← back to Norma Platform

security: fix cycle-1 defects — add requireRole to 5 social routes (were pulse-open), validate norma-imp-by token in impersonate DELETE (privilege-escalation), HMAC-verify registry session (was 403-for-all + crafted-token bypass); add scratch-DB test instance + durable test suite

f63804e037c4ff2e988d17d09c3c48ea65413458 · 2026-08-05 13:27:35 -0700 · Steve

Files touched

Diff

commit f63804e037c4ff2e988d17d09c3c48ea65413458
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 5 13:27:35 2026 -0700

    security: fix cycle-1 defects — add requireRole to 5 social routes (were pulse-open), validate norma-imp-by token in impersonate DELETE (privilege-escalation), HMAC-verify registry session (was 403-for-all + crafted-token bypass); add scratch-DB test instance + durable test suite
---
 app/api/admin/impersonate/route.ts          | 12 +++++++++++-
 app/api/registry/route.ts                   | 23 +++++++----------------
 app/api/social/ai-image/route.ts            |  3 +++
 app/api/social/analytics/dashboard/route.ts |  3 +++
 app/api/social/bulk-schedule/route.ts       |  7 ++++++-
 app/api/social/posts/approve/route.ts       |  3 +++
 app/api/social/posts/pending/route.ts       |  5 ++++-
 lib/auth.ts                                 |  2 +-
 8 files changed, 38 insertions(+), 20 deletions(-)

diff --git a/app/api/admin/impersonate/route.ts b/app/api/admin/impersonate/route.ts
index 58bbe6e..e90aaaa 100644
--- a/app/api/admin/impersonate/route.ts
+++ b/app/api/admin/impersonate/route.ts
@@ -18,7 +18,7 @@
 
 import { NextRequest, NextResponse } from 'next/server';
 import {
-  createSession, buildAuthCookie, AUTH_COOKIE_NAME, verifyAuth,
+  createSession, buildAuthCookie, AUTH_COOKIE_NAME, verifyAuth, verifySessionToken,
 } from '@/lib/auth';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
@@ -117,6 +117,16 @@ export async function DELETE(request: NextRequest) {
     return NextResponse.json({ error: 'Not currently impersonating' }, { status: 400 });
   }
 
+  // SECURITY: only restore a genuinely HMAC-signed session token. Never set an
+  // arbitrary imp-by cookie value as the live session — doing so let any user
+  // inject a session token they placed in norma-imp-by (privilege escalation).
+  const restored = verifySessionToken(restoreToken);
+  if (!restored) {
+    const bad = NextResponse.json({ error: 'Invalid impersonation cookie' }, { status: 400 });
+    bad.headers.append('Set-Cookie', clearImpByCookie());
+    return bad;
+  }
+
   const response = NextResponse.json({ success: true, restored: true });
   response.headers.append('Set-Cookie', buildAuthCookie(restoreToken));
   response.headers.append('Set-Cookie', clearImpByCookie());
diff --git a/app/api/registry/route.ts b/app/api/registry/route.ts
index 435f51a..8289ddc 100644
--- a/app/api/registry/route.ts
+++ b/app/api/registry/route.ts
@@ -6,29 +6,20 @@
 import { NextResponse } from 'next/server';
 import { cookies } from 'next/headers';
 import { INTEGRATIONS, CATEGORY_ORDER, statusFor } from '@/lib/api-registry';
+import { verifySessionToken, type AuthSession } from '@/lib/auth';
 
 export const runtime = 'nodejs';
 export const dynamic = 'force-dynamic';
 
-interface SessionPayload {
-  role?: string;
-  user?: string;
-}
-
-async function readSession(): Promise<SessionPayload | null> {
+// SECURITY: verify the HMAC-signed session token — do NOT "tolerant-decode" the
+// cookie. The old code read parts[1] (the signature, not the payload) so real
+// admins always got 403, AND an unsigned crafted token could have claimed
+// role:admin. verifySessionToken checks the HMAC signature + expiry.
+async function readSession(): Promise<AuthSession | null> {
   const jar = await cookies();
   const raw = jar.get('norma-auth')?.value;
   if (!raw) return null;
-  try {
-    // The cookie is JWT-style or signed-JSON depending on lib/auth.ts; we
-    // only need the payload to check the role. Tolerant decode.
-    const parts = raw.split('.');
-    const body = parts.length >= 2 ? parts[1] : parts[0];
-    const json = Buffer.from(body, 'base64url').toString('utf8');
-    return JSON.parse(json) as SessionPayload;
-  } catch {
-    return null;
-  }
+  return verifySessionToken(raw);
 }
 
 export async function GET() {
diff --git a/app/api/social/ai-image/route.ts b/app/api/social/ai-image/route.ts
index a1827bd..fb3578c 100644
--- a/app/api/social/ai-image/route.ts
+++ b/app/api/social/ai-image/route.ts
@@ -3,6 +3,7 @@ import { randomUUID } from 'crypto';
 import { promises as fs } from 'fs';
 import path from 'path';
 import { query } from '@/lib/db';
+import { requireRole } from '@/lib/require-role';
 
 export const dynamic = 'force-dynamic';
 
@@ -18,6 +19,8 @@ const OUT_DIR = '/root/Projects/Norma/public/social-generated';
  * Body: { prompt: string, post_id?: uuid }
  */
 export async function POST(request: NextRequest) {
+  const auth = requireRole(request, 'admin', 'staff');
+  if (auth instanceof NextResponse) return auth;
   let body: { prompt?: string; post_id?: string };
   try {
     body = await request.json();
diff --git a/app/api/social/analytics/dashboard/route.ts b/app/api/social/analytics/dashboard/route.ts
index 20d64f2..8d03e37 100644
--- a/app/api/social/analytics/dashboard/route.ts
+++ b/app/api/social/analytics/dashboard/route.ts
@@ -1,5 +1,6 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { query } from '@/lib/db';
+import { requireRole } from '@/lib/require-role';
 
 export const dynamic = 'force-dynamic';
 
@@ -12,6 +13,8 @@ export const dynamic = 'force-dynamic';
  * Returns { trends, top_posts, heatmap, summary }
  */
 export async function GET(request: NextRequest) {
+  const auth = requireRole(request, 'admin', 'staff');
+  if (auth instanceof NextResponse) return auth;
   try {
     const { searchParams } = new URL(request.url);
     const daysParam = parseInt(searchParams.get('days') ?? '30', 10);
diff --git a/app/api/social/bulk-schedule/route.ts b/app/api/social/bulk-schedule/route.ts
index 5202cc8..03bcdae 100644
--- a/app/api/social/bulk-schedule/route.ts
+++ b/app/api/social/bulk-schedule/route.ts
@@ -1,5 +1,6 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { query, getClient } from '@/lib/db';
+import { requireRole } from '@/lib/require-role';
 
 export const dynamic = 'force-dynamic';
 
@@ -63,7 +64,9 @@ function businessHoursSchedule(startMs: number, endMs: number, n: number): Date[
 /**
  * GET /api/social/bulk-schedule — last 20 bulk imports with stats.
  */
-export async function GET(_request: NextRequest) {
+export async function GET(request: NextRequest) {
+  const auth = requireRole(request, 'admin', 'staff');
+  if (auth instanceof NextResponse) return auth;
   try {
     const result = await query(
       `SELECT id, source, total_rows, successful, failed, distribution,
@@ -86,6 +89,8 @@ export async function GET(_request: NextRequest) {
  * POST /api/social/bulk-schedule
  */
 export async function POST(request: NextRequest) {
+  const auth = requireRole(request, 'admin', 'staff');
+  if (auth instanceof NextResponse) return auth;
   let body: BulkScheduleBody;
   try {
     body = await request.json();
diff --git a/app/api/social/posts/approve/route.ts b/app/api/social/posts/approve/route.ts
index 560d8ab..3f3b6c3 100644
--- a/app/api/social/posts/approve/route.ts
+++ b/app/api/social/posts/approve/route.ts
@@ -1,5 +1,6 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { query, getClient } from '@/lib/db';
+import { requireRole } from '@/lib/require-role';
 
 export const dynamic = 'force-dynamic';
 
@@ -8,6 +9,8 @@ export const dynamic = 'force-dynamic';
  * Body: { post_id, action: 'approve'|'reject', approver, reason?, scheduled_at? }
  */
 export async function POST(request: NextRequest) {
+  const auth = requireRole(request, 'admin', 'staff');
+  if (auth instanceof NextResponse) return auth;
   let body: {
     post_id?: string;
     action?: 'approve' | 'reject';
diff --git a/app/api/social/posts/pending/route.ts b/app/api/social/posts/pending/route.ts
index b3539b3..08bc10d 100644
--- a/app/api/social/posts/pending/route.ts
+++ b/app/api/social/posts/pending/route.ts
@@ -1,5 +1,6 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { query } from '@/lib/db';
+import { requireRole } from '@/lib/require-role';
 
 export const dynamic = 'force-dynamic';
 
@@ -7,7 +8,9 @@ export const dynamic = 'force-dynamic';
  * GET /api/social/posts/pending
  * Returns posts awaiting approval.
  */
-export async function GET(_request: NextRequest) {
+export async function GET(request: NextRequest) {
+  const auth = requireRole(request, 'admin', 'staff');
+  if (auth instanceof NextResponse) return auth;
   try {
     const result = await query(
       `SELECT
diff --git a/lib/auth.ts b/lib/auth.ts
index 9b61ae3..087e158 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -87,7 +87,7 @@ export function createSession(username: string, role: string, orgId: string | nu
  * Supports new format (contains '.') and old format (colon-delimited) for backward compat.
  * Returns AuthSession if valid, or null if invalid / expired.
  */
-function verifySessionToken(token: string): AuthSession | null {
+export function verifySessionToken(token: string): AuthSession | null {
   try {
     // New format: base64payload.signature
     if (token.includes('.')) {

← d9cb4a2 auto-save: 2026-08-05T13:12:15 (2 files) — scripts/test-inst  ·  back to Norma Platform  ·  security(A-prime): scope login brute-force limiter per (ip,u e0ef009 →