CognxSafeTrack commited on
Commit Β·
ec1b111
1
Parent(s): ea8815c
feat(settings): expose branding logoUrl and primaryColor fields
Browse filesAdds a Branding section to SettingsPage that reads/writes
org.brandingData.logoUrl and org.brandingData.primaryColor.
Includes a live logo preview and a color picker + hex input.
test(security): add 15 SQL injection and auth tests
Pure unit checks for the DANGEROUS_PATTERNS set used in the
text-to-SQL guard (UNION, DELETE, DROP, comments, stacked queries).
Integration auth checks for super-admin JWT enforcement.
apps/admin/src/pages/SettingsPage.tsx
CHANGED
|
@@ -338,6 +338,46 @@ export default function SettingsPage() {
|
|
| 338 |
</p>
|
| 339 |
</section>
|
| 340 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
{/* AI API Keys β gated by subscription plan */}
|
| 342 |
{(() => {
|
| 343 |
const plan = org.subscriptionPlan ?? 'STARTER';
|
|
|
|
| 338 |
</p>
|
| 339 |
</section>
|
| 340 |
|
| 341 |
+
{/* Branding */}
|
| 342 |
+
<section className="bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
|
| 343 |
+
<h2 className="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
|
| 344 |
+
<span>π¨</span> Branding
|
| 345 |
+
</h2>
|
| 346 |
+
<div className="space-y-4">
|
| 347 |
+
<div>
|
| 348 |
+
<label className="block text-sm font-medium text-slate-600 mb-1">URL du logo</label>
|
| 349 |
+
<input
|
| 350 |
+
type="url"
|
| 351 |
+
placeholder="https://storage.com/logo.png"
|
| 352 |
+
value={(org.brandingData as any)?.logoUrl || ''}
|
| 353 |
+
onChange={e => setOrg({ ...org, brandingData: { ...(org.brandingData ?? {}), logoUrl: e.target.value } })}
|
| 354 |
+
className="w-full px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none text-sm"
|
| 355 |
+
/>
|
| 356 |
+
{(org.brandingData as any)?.logoUrl && (
|
| 357 |
+
<img src={(org.brandingData as any).logoUrl} alt="logo preview" className="mt-2 h-10 object-contain rounded" onError={e => (e.currentTarget.style.display = 'none')} />
|
| 358 |
+
)}
|
| 359 |
+
</div>
|
| 360 |
+
<div>
|
| 361 |
+
<label className="block text-sm font-medium text-slate-600 mb-1">Couleur principale</label>
|
| 362 |
+
<div className="flex items-center gap-3">
|
| 363 |
+
<input
|
| 364 |
+
type="color"
|
| 365 |
+
value={(org.brandingData as any)?.primaryColor || '#6366f1'}
|
| 366 |
+
onChange={e => setOrg({ ...org, brandingData: { ...(org.brandingData ?? {}), primaryColor: e.target.value } })}
|
| 367 |
+
className="w-10 h-10 rounded-lg border border-slate-200 cursor-pointer p-0.5"
|
| 368 |
+
/>
|
| 369 |
+
<input
|
| 370 |
+
type="text"
|
| 371 |
+
value={(org.brandingData as any)?.primaryColor || '#6366f1'}
|
| 372 |
+
onChange={e => setOrg({ ...org, brandingData: { ...(org.brandingData ?? {}), primaryColor: e.target.value } })}
|
| 373 |
+
placeholder="#6366f1"
|
| 374 |
+
className="flex-1 px-4 py-2 border border-slate-200 rounded-xl focus:ring-2 focus:ring-emerald-500 outline-none text-sm font-mono"
|
| 375 |
+
/>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</div>
|
| 379 |
+
</section>
|
| 380 |
+
|
| 381 |
{/* AI API Keys β gated by subscription plan */}
|
| 382 |
{(() => {
|
| 383 |
const plan = org.subscriptionPlan ?? 'STARTER';
|
apps/api/test/security.test.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
| 2 |
+
import { buildApp } from '../src/app';
|
| 3 |
+
|
| 4 |
+
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 5 |
+
|
| 6 |
+
function makeJwt(app: any, payload: object) {
|
| 7 |
+
return (app as any).jwt.sign(payload);
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
// βββ Suite ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
|
| 12 |
+
describe('Security β SQL Injection & Role Guards', () => {
|
| 13 |
+
let app: any;
|
| 14 |
+
|
| 15 |
+
beforeAll(async () => {
|
| 16 |
+
app = await buildApp();
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
afterAll(async () => {
|
| 20 |
+
await app.close();
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
// ββ Analytics text-to-SQL injection protection (pure logic) βββββββββββββ
|
| 24 |
+
// HTTP integration tests for analytics/query require a real DB org (the
|
| 25 |
+
// injectTenantConfig middleware runs before the route). Pattern validation
|
| 26 |
+
// is tested here as pure unit checks.
|
| 27 |
+
|
| 28 |
+
describe('SQL injection DANGEROUS_PATTERNS β unit checks', () => {
|
| 29 |
+
// Mirror the exact patterns from analytics.ts
|
| 30 |
+
const DANGEROUS_PATTERNS = [/\bUNION\b/, /\bINSERT\b/, /\bUPDATE\b/, /\bDELETE\b/, /\bDROP\b/, /\bEXEC\b/, /\bEXECUTE\b/, /--/, /\/\*/, /;\s*SELECT/i];
|
| 31 |
+
|
| 32 |
+
it('blocks UNION SELECT', () => {
|
| 33 |
+
const sql = "SELECT * FROM \"User\" WHERE \"organizationId\" = 'x' UNION SELECT * FROM \"User\"";
|
| 34 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test(sql.toUpperCase()))).toBe(true);
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
it('blocks DROP TABLE', () => {
|
| 38 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test('DROP TABLE "User"'.toUpperCase()))).toBe(true);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
it('blocks -- SQL comment', () => {
|
| 42 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test('SELECT 1 -- bypass filter'))).toBe(true);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it('blocks /* block comment */', () => {
|
| 46 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test('SELECT 1 /* comment */'))).toBe(true);
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it('blocks DELETE', () => {
|
| 50 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test('DELETE FROM "User" WHERE 1=1'.toUpperCase()))).toBe(true);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it('blocks INSERT', () => {
|
| 54 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test('INSERT INTO "User" VALUES (1)'.toUpperCase()))).toBe(true);
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
it('blocks UPDATE', () => {
|
| 58 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test('UPDATE "User" SET name=\'x\''.toUpperCase()))).toBe(true);
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it('blocks stacked query with ; SELECT', () => {
|
| 62 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test('SELECT 1; SELECT * FROM "User"'))).toBe(true);
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it('allows safe SELECT without dangerous patterns', () => {
|
| 66 |
+
const sql = 'SELECT id, name FROM "User" WHERE "organizationId" = \'abc\' LIMIT 10';
|
| 67 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test(sql.toUpperCase()))).toBe(false);
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it('allows SELECT with GROUP BY and ORDER BY', () => {
|
| 71 |
+
const sql = 'SELECT feature, COUNT(*) as total FROM "UsageEvent" WHERE "organizationId" = \'abc\' GROUP BY feature ORDER BY total DESC LIMIT 5';
|
| 72 |
+
expect(DANGEROUS_PATTERNS.some(p => p.test(sql.toUpperCase()))).toBe(false);
|
| 73 |
+
});
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
// ββ Admin route unauthenticated βββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
// Role guard (STUDENT/ORG_MEMBER β 403) requires a real DB org record because
|
| 78 |
+
// injectTenantConfig runs before the route-level role check. The integration
|
| 79 |
+
// tests in critical-flows.test.ts cover the authenticated path.
|
| 80 |
+
|
| 81 |
+
describe('GET /v1/admin/stats β auth check', () => {
|
| 82 |
+
it('returns 401 with no token', async () => {
|
| 83 |
+
const res = await app.inject({ method: 'GET', url: '/v1/admin/stats' });
|
| 84 |
+
expect(res.statusCode).toBe(401);
|
| 85 |
+
});
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
// ββ Super-admin route authentication & authorization βββββββββββββββββββββ
|
| 89 |
+
|
| 90 |
+
describe('GET /v1/super-admin/platform/stats β auth & role', () => {
|
| 91 |
+
it('returns 401 with no token', async () => {
|
| 92 |
+
const res = await app.inject({ method: 'GET', url: '/v1/super-admin/platform/stats' });
|
| 93 |
+
expect(res.statusCode).toBe(401);
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
it('returns 403 for ORG_ADMIN role', async () => {
|
| 97 |
+
const token = makeJwt(app, { id: 'u1', role: 'ORG_ADMIN', organizationId: 'org1' });
|
| 98 |
+
const res = await app.inject({
|
| 99 |
+
method: 'GET',
|
| 100 |
+
url: '/v1/super-admin/platform/stats',
|
| 101 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 102 |
+
});
|
| 103 |
+
expect(res.statusCode).toBe(403);
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
it('returns 403 for STUDENT role', async () => {
|
| 107 |
+
const token = makeJwt(app, { id: 'u1', role: 'STUDENT', organizationId: 'org1' });
|
| 108 |
+
const res = await app.inject({
|
| 109 |
+
method: 'GET',
|
| 110 |
+
url: '/v1/super-admin/platform/stats',
|
| 111 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 112 |
+
});
|
| 113 |
+
expect(res.statusCode).toBe(403);
|
| 114 |
+
});
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
// ββ JWT secret enforcement ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
+
|
| 119 |
+
describe('JWT tampered token', () => {
|
| 120 |
+
it('returns 401 for a tampered JWT', async () => {
|
| 121 |
+
const res = await app.inject({
|
| 122 |
+
method: 'GET',
|
| 123 |
+
url: '/v1/admin/stats',
|
| 124 |
+
headers: { Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZha2UifQ.invalidsignature' },
|
| 125 |
+
});
|
| 126 |
+
expect(res.statusCode).toBe(401);
|
| 127 |
+
});
|
| 128 |
+
});
|
| 129 |
+
});
|