← 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
M app/api/admin/impersonate/route.tsM app/api/registry/route.tsM app/api/social/ai-image/route.tsM app/api/social/analytics/dashboard/route.tsM app/api/social/bulk-schedule/route.tsM app/api/social/posts/approve/route.tsM app/api/social/posts/pending/route.tsM lib/auth.ts
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 →