-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhooks.server.ts
More file actions
159 lines (138 loc) · 5.38 KB
/
hooks.server.ts
File metadata and controls
159 lines (138 loc) · 5.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import { BASIC_AUTH_PASSWORD, BASIC_AUTH_USERNAME, JWT_SECRET } from '$env/static/private'
import { logger } from '$lib/logger'
import type { Handle } from '@sveltejs/kit'
import { redirect } from '@sveltejs/kit'
import { sequence } from '@sveltejs/kit/hooks'
import jwt from 'jsonwebtoken'
const MAX_LOGIN_ATTEMPTS = 5
const LOGIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000 // 15 minutes
const PUBLIC_PATHS = new Set([
'/login',
'/favicon.ico',
'/robots.txt',
'/health',
])
// Validate environment variables
if (!BASIC_AUTH_USERNAME || !BASIC_AUTH_PASSWORD) {
logger.error('FATAL: BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD must be set')
process.exit(1)
}
// Simple in-memory rate limiting
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>()
const securityHeaders = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'geolocation=(), microphone=(), camera=()',
}
const logSecurityEvent = (event: string, details: Record<string, unknown> = {}) => {
logger.debug(
JSON.stringify({
timestamp: new Date().toISOString(),
type: 'security',
event,
...details,
}),
)
}
const authMiddleware: Handle = async ({ event, resolve }) => {
const { pathname } = event.url
let clientIP: string
try {
clientIP = event.getClientAddress()
} catch (e: unknown) {
logger.warn(
`[AUTH] Could not determine clientAddress: ${e instanceof Error ? e.message : String(e)}. Rate limiting may be less accurate for this request if multiple clients fail IP detection.`,
)
clientIP = 'unknown'
}
const cookieHeader = event.request.headers.get('cookie') || ''
const cookieNames = cookieHeader
.split(';')
.map((c) => c.split('=')[0]?.trim())
.filter(Boolean)
logger.debug('[AUTH] Incoming request:', {
pathname,
clientIP,
cookies: cookieNames,
})
// Skip auth for public paths
if (
pathname.startsWith('/login') ||
pathname.startsWith('/_app/') ||
PUBLIC_PATHS.has(pathname)
) {
logger.debug('[AUTH] Allowed public path:', pathname)
return resolve(event)
}
// Check if user is authenticated via JWT
const token = event.cookies.get('auth_token')
let isAuthenticated = false
if (!JWT_SECRET) {
logger.error(
'[AUTH] JWT_SECRET is not defined. Cannot verify JWTs in middleware. Denying access.',
)
// This is a server configuration error, but we still redirect to login to prevent access.
} else if (token) {
try {
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] })
isAuthenticated = true
logger.debug('[AUTH] JWT verification successful in middleware for path:', pathname)
} catch (error: unknown) {
let reason = 'Invalid token'
let errorName = 'UnknownError'
if (error instanceof Error) {
errorName = error.name
if (error.name === 'TokenExpiredError') {
reason = 'Token expired'
} else if (error.name === 'JsonWebTokenError') {
reason = 'Token malformed or signature invalid'
}
}
logger.warn(
`[AUTH] JWT verification failed in middleware: ${errorName} - ${reason} for path: ${pathname}`,
)
logSecurityEvent('jwt_verification_failed_middleware', {
ip: clientIP,
path: pathname,
reason,
errorName,
})
// Clear the invalid/expired cookie to prevent redirect loops or issues
event.cookies.delete('auth_token', { path: '/' })
}
} else {
logger.debug('[AUTH] No auth_token cookie found in middleware for path:', pathname)
}
if (isAuthenticated) {
// User is authenticated - add security headers to response
const response = await resolve(event)
for (const [key, value] of Object.entries(securityHeaders)) {
response.headers.set(key, value)
}
logger.debug('[AUTH] Authenticated via JWT, returning response for path:', pathname)
return response
}
// Check rate limiting
const attemptInfo = loginAttempts.get(clientIP) || { count: 0, lastAttempt: 0 }
// Reset counter if window has passed
if (Date.now() - attemptInfo.lastAttempt > LOGIN_ATTEMPT_WINDOW_MS) {
attemptInfo.count = 1
} else {
attemptInfo.count += 1
}
attemptInfo.lastAttempt = Date.now()
loginAttempts.set(clientIP, attemptInfo)
if (attemptInfo.count >= MAX_LOGIN_ATTEMPTS) {
logSecurityEvent('rate_limit_exceeded', { ip: clientIP, path: pathname })
logger.debug('[AUTH] Too many attempts for IP:', clientIP)
// We still redirect to login page, but with an error parameter
throw redirect(303, '/login?error=too_many_attempts')
}
// Not authenticated - redirect to login page
logSecurityEvent('auth_required', { ip: clientIP, path: pathname })
logger.debug('[AUTH] Not authenticated, redirecting to /login for', pathname)
throw redirect(303, '/login')
}
export const handle = sequence(authMiddleware)