Skip to content

Commit 31c5bda

Browse files
authored
Merge pull request #47 from ITSEC-Research/tomi-dev
Tomi dev
2 parents 778319b + d4e8dd2 commit 31c5bda

File tree

61 files changed

+7249
-1583
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+7249
-1583
lines changed

.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
# 3. docker-compose up -d --build
1010
# =====================================================
1111

12+
# -----------------------------------------------------------
13+
# AUTHENTICATION
14+
# -----------------------------------------------------------
15+
# JWT secret for signing authentication tokens
16+
# IMPORTANT: Generate a strong random secret (minimum 32 characters)
17+
# Example: openssl rand -hex 32
18+
JWT_SECRET=super_secret_random_string_at_least_32_chars_long
19+
1220
# -----------------------------------------------------------
1321
# MAIN DATABASE (MySQL/MariaDB)
1422
# -----------------------------------------------------------
@@ -41,6 +49,15 @@ CLICKHOUSE_DB=bronvault_analytics
4149
SYNC_USER=clickhouse_sync
4250
SYNC_PASSWORD=change_me_sync_password_123
4351

52+
# -----------------------------------------------------------
53+
# OBJECT STORAGE (MinIO - S3 Compatible)
54+
# -----------------------------------------------------------
55+
# MinIO root credentials for the admin console
56+
# Console accessible at http://localhost:9002
57+
# S3 API accessible at http://localhost:9001
58+
MINIO_ROOT_USER=minioadmin
59+
MINIO_ROOT_PASSWORD=minioadmin
60+
4461
# =====================================================
4562
# IMPORTANT SECURITY NOTES:
4663
# =====================================================

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ uploads/*
77
error.log
88
clickhouse-data
99
mysql-data
10+
minio-data
1011
tmp
1112
.DS_Store
1213
.devtasks

app/api/auth/change-password/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { pool } from "@/lib/mysql";
33
import bcrypt from "bcryptjs";
44
import type { RowDataPacket } from "mysql2";
55
import { validateRequest } from "@/lib/auth";
6+
import { passwordSchema } from "@/lib/validation";
67

78
export async function POST(request: NextRequest) {
89
const { currentPassword, newPassword } = await request.json();
@@ -14,6 +15,15 @@ export async function POST(request: NextRequest) {
1415
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
1516
}
1617

18+
// SECURITY: Validate new password strength (MED-01)
19+
const passwordValidation = passwordSchema.safeParse(newPassword);
20+
if (!passwordValidation.success) {
21+
return NextResponse.json(
22+
{ success: false, error: passwordValidation.error.errors.map(e => e.message).join(", ") },
23+
{ status: 400 }
24+
);
25+
}
26+
1727
try {
1828
// Get current user data
1929
const [users] = await pool.query<RowDataPacket[]>(

app/api/auth/login/route.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,26 @@ export async function POST(request: NextRequest) {
6565
// This token is short-lived (5 minutes) and can only be used for 2FA verification
6666
const pending2FAToken = await generatePending2FAToken(String(user.id))
6767

68-
// Return requires2FA flag with secure pending token - don't issue auth token yet
69-
return NextResponse.json({
68+
// SECURITY: Store pending2FAToken in httpOnly cookie instead of response body (HIGH-02)
69+
const response = NextResponse.json({
7070
success: true,
7171
requires2FA: true,
72-
pending2FAToken, // Secure token that proves password was verified
7372
message: "Please enter your 2FA code"
7473
})
74+
75+
response.cookies.set("pending_2fa", pending2FAToken, {
76+
httpOnly: true,
77+
secure: process.env.NODE_ENV === 'production',
78+
sameSite: 'strict',
79+
path: '/api/auth/verify-totp',
80+
maxAge: 300, // 5 minutes
81+
})
82+
83+
return response
7584
}
7685

77-
// Get user role - default to 'admin' for backwards compatibility
78-
const userRole: UserRole = user.role || 'admin'
86+
// Get user role - default to 'analyst' for least privilege
87+
const userRole: UserRole = user.role || 'analyst'
7988

8089
// Generate JWT token with role included
8190
const token = await generateToken({

app/api/auth/register-first-user/route.ts

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ export async function POST(request: NextRequest) {
2727
}, { status: 400 })
2828
}
2929

30-
// Validate password strength - matches validation.ts requirements
31-
if (password.length < 8) {
30+
// Validate password strength - matches validation.ts requirements (LOW-01)
31+
if (password.length < 12) {
3232
return NextResponse.json({
3333
success: false,
34-
error: "Password must be at least 8 characters long"
34+
error: "Password must be at least 12 characters long"
3535
}, { status: 400 })
3636
}
3737

@@ -54,6 +54,12 @@ export async function POST(request: NextRequest) {
5454
error: "Password must contain at least one number"
5555
}, { status: 400 })
5656
}
57+
if (!/[^A-Za-z0-9]/.test(password)) {
58+
return NextResponse.json({
59+
success: false,
60+
error: "Password must contain at least one special character"
61+
}, { status: 400 })
62+
}
5763

5864
// Check if users table exists, create if not
5965
const [tables] = await pool.query<RowDataPacket[]>(
@@ -83,41 +89,27 @@ export async function POST(request: NextRequest) {
8389
}
8490

8591
// SECURITY CHECK: Ensure no users exist before allowing registration
86-
const [existingUsers] = await pool.query<RowDataPacket[]>(
87-
"SELECT COUNT(*) as count FROM users"
92+
// Use atomic INSERT...SELECT to prevent race condition (MED-05)
93+
// If another request created a user between our check and insert, this will insert 0 rows
94+
const hashedPassword = await bcrypt.hash(password, 12)
95+
96+
const [insertResult] = await pool.query<RowDataPacket[]>(
97+
`INSERT INTO users (email, password_hash, name, role)
98+
SELECT ?, ?, ?, 'admin'
99+
FROM DUAL
100+
WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)`,
101+
[email, hashedPassword, name]
88102
)
89103

90-
const userCount = Array.isArray(existingUsers) && existingUsers.length > 0 ? existingUsers[0].count : 0
104+
const affectedRows = (insertResult as any).affectedRows || 0
91105

92-
if (userCount > 0) {
106+
if (affectedRows === 0) {
93107
return NextResponse.json({
94108
success: false,
95109
error: "Registration is only allowed when no users exist. Please use login instead."
96110
}, { status: 403 })
97111
}
98112

99-
// Check if email already exists (extra safety)
100-
const [emailCheck] = await pool.query<RowDataPacket[]>(
101-
"SELECT id FROM users WHERE email = ? LIMIT 1",
102-
[email]
103-
)
104-
105-
if (Array.isArray(emailCheck) && emailCheck.length > 0) {
106-
return NextResponse.json({
107-
success: false,
108-
error: "Email already exists"
109-
}, { status: 400 })
110-
}
111-
112-
// Hash password
113-
const hashedPassword = await bcrypt.hash(password, 12)
114-
115-
// Create first user - always admin role for first user
116-
await pool.query(
117-
"INSERT INTO users (email, password_hash, name, role) VALUES (?, ?, ?, 'admin')",
118-
[email, hashedPassword, name]
119-
)
120-
121113
console.log("✅ First user created successfully:", email)
122114

123115
return NextResponse.json({

app/api/auth/verify-totp/route.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import type { RowDataPacket } from "mysql2"
1616
import { logUserAction } from "@/lib/audit-log"
1717

1818
export async function POST(request: NextRequest) {
19-
const { pending2FAToken, code, isBackupCode } = await request.json()
19+
const { code, isBackupCode } = await request.json()
20+
21+
// SECURITY: Read pending2FAToken from httpOnly cookie instead of request body (HIGH-02)
22+
const pending2FAToken = request.cookies.get('pending_2fa')?.value
2023

2124
// SECURITY: Validate pending 2FA token instead of accepting userId directly
2225
// This ensures the user has already passed password authentication
@@ -95,7 +98,7 @@ export async function POST(request: NextRequest) {
9598
}
9699

97100
// Generate JWT token
98-
const userRole: UserRole = userData.role || 'admin'
101+
const userRole: UserRole = userData.role || 'analyst'
99102
const token = await generateToken({
100103
userId: String(userData.id),
101104
username: userData.name || userData.email,
@@ -125,6 +128,15 @@ export async function POST(request: NextRequest) {
125128

126129
response.cookies.set("auth", token, getSecureCookieOptions())
127130

131+
// SECURITY: Clear the pending_2fa cookie after successful verification
132+
response.cookies.set("pending_2fa", "", {
133+
httpOnly: true,
134+
secure: process.env.NODE_ENV === 'production',
135+
sameSite: 'strict',
136+
path: '/api/auth/verify-totp',
137+
maxAge: 0,
138+
})
139+
128140
return response
129141
} catch (err) {
130142
console.error("Verify TOTP error:", err)

app/api/browser-analysis/route.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export async function GET(request: NextRequest) {
5757
// Build date filter - credentials are linked to devices, so we filter by device_id
5858
// Get device_ids first, then use them in the query
5959
let deviceFilter = ""
60+
let deviceFilterParams: Record<string, unknown> = {}
6061
if (hasDateFilter) {
6162
const { whereClause: deviceDateFilter } = buildDeviceDateFilter(dateFilter)
6263
const { whereClause: systemInfoDateFilter } = buildSystemInfoDateFilter(dateFilter)
@@ -86,9 +87,9 @@ export async function GET(request: NextRequest) {
8687
return NextResponse.json({ success: true, browserAnalysis: [] })
8788
}
8889

89-
// Use array format for ClickHouse IN clause
90-
const deviceIdsStr = deviceIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ')
91-
deviceFilter = `AND device_id IN (${deviceIdsStr})`
90+
// SECURITY: Use parameterized array for ClickHouse IN clause (CRIT-09)
91+
deviceFilter = `AND device_id IN {filterDeviceIds:Array(String)}`
92+
deviceFilterParams = { filterDeviceIds: deviceIds }
9293
}
9394

9495
// Get all (device_id, browser) pairs to properly count unique devices per normalized browser
@@ -99,7 +100,7 @@ export async function GET(request: NextRequest) {
99100
FROM credentials
100101
WHERE browser IS NOT NULL AND browser != '' ${deviceFilter}`
101102

102-
const results = await executeClickHouseQuery(baseQuery) as any[];
103+
const results = await executeClickHouseQuery(baseQuery, deviceFilterParams) as any[];
103104

104105
if (!Array.isArray(results)) {
105106
return NextResponse.json({ success: false, error: "Invalid data format" }, { status: 500 });

app/api/domain-recon/credentials/route.ts

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"
22
import { executeQuery as executeClickHouseQuery } from "@/lib/clickhouse"
33
import { validateRequest } from "@/lib/auth"
44
import { throwIfAborted, getRequestSignal, handleAbortError } from "@/lib/api-helpers"
5+
import { parseSearchQuery } from "@/lib/query-parser"
6+
import { buildDomainReconCondition, buildKeywordReconCondition } from "@/lib/search-query-builder"
57

68
export async function POST(request: NextRequest) {
79
// ✅ Check abort VERY EARLY - before validateRequest
@@ -24,11 +26,13 @@ export async function POST(request: NextRequest) {
2426
}
2527

2628
// Normalize Domain
27-
let cleanDomain = targetDomain.trim().toLowerCase()
28-
cleanDomain = cleanDomain.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].split(':')[0]
29+
const cleanDomain = targetDomain.trim().toLowerCase()
30+
31+
// Parse query for operator support (OR, NOT, wildcard, exact)
32+
const parsed = parseSearchQuery(cleanDomain)
2933

3034
// Cleaner log: only show search if present
31-
const logData: any = { type: searchType, domain: cleanDomain }
35+
const logData: any = { type: searchType, domain: cleanDomain, terms: parsed.terms.length }
3236
if (searchQuery && searchQuery.trim()) {
3337
logData.search = searchQuery.trim()
3438
}
@@ -41,7 +45,7 @@ export async function POST(request: NextRequest) {
4145
throwIfAborted(request)
4246

4347
// Call the new data getter function
44-
const credentialsData = await getCredentialsDataOptimized(cleanDomain, filters, pagination, searchQuery, searchType, body.keywordMode, signal)
48+
const credentialsData = await getCredentialsDataOptimized(parsed, filters, pagination, searchQuery, searchType, body.keywordMode, signal)
4549

4650
// Check abort after operations
4751
throwIfAborted(request)
@@ -71,7 +75,7 @@ export async function POST(request: NextRequest) {
7175
}
7276

7377
async function getCredentialsDataOptimized(
74-
query: string,
78+
parsed: import("@/lib/query-parser").ParsedQuery,
7579
filters?: any,
7680
pagination?: any,
7781
searchQuery?: string,
@@ -99,44 +103,19 @@ async function getCredentialsDataOptimized(
99103
// ==========================================
100104
// 1. BUILD PREWHERE (Main Table Filters)
101105
// ==========================================
102-
// PREWHERE is the key to speed in ClickHouse.
103-
// It filters before JOIN and before reading heavy columns.
106+
// Use the shared query builder for the main domain/keyword condition
104107

105108
const prewhereConditions: string[] = []
106109
const params: Record<string, any> = {}
107110

108111
if (searchType === 'domain') {
109-
// DOMAIN OPTIMIZATION:
110-
// 1. Check Exact Match domain
111-
// 2. Check Subdomain using endsWith (much faster than ilike/regex)
112-
// 3. Fallback to URL pattern match only if needed
113-
114-
params['targetDomain'] = query
115-
params['dotTargetDomain'] = '.' + query
116-
117-
// Logic: Domain column exact match OR Domain column ends with .target.com
118-
// This leverages suffix index if available, or at least fast string scan
119-
prewhereConditions.push(`(
120-
c.domain = {targetDomain:String} OR
121-
endsWith(c.domain, {dotTargetDomain:String}) OR
122-
c.url ilike {urlPattern:String}
123-
)`)
124-
// Fallback URL pattern for catch-all
125-
params['urlPattern'] = `%${query}%`
126-
112+
const built = buildDomainReconCondition(parsed, { notNullCheck: false })
113+
prewhereConditions.push(`(${built.condition})`)
114+
Object.assign(params, built.params)
127115
} else {
128-
// KEYWORD SEARCH
129-
params['keyword'] = query
130-
params['likeKeyword'] = `%${query}%`
131-
132-
if (keywordMode === 'domain-only') {
133-
prewhereConditions.push(`(c.domain ilike {likeKeyword:String})`)
134-
} else {
135-
// Optimization: multiSearchAnyCase is faster than OR OR OR
136-
// But for simplicity and param binding, we use ilike in PREWHERE
137-
// because PREWHERE already significantly reduces cost.
138-
prewhereConditions.push(`(c.url ilike {likeKeyword:String} OR c.domain ilike {likeKeyword:String})`)
139-
}
116+
const built = buildKeywordReconCondition(parsed, keywordMode)
117+
prewhereConditions.push(`(${built.condition})`)
118+
Object.assign(params, built.params)
140119
}
141120

142121
// Additional Filters to PREWHERE (To filter faster at the start)

0 commit comments

Comments
 (0)