33 * SPDX-License-Identifier: Apache-2.0
44 */
55
6+ import crypto from 'crypto' ;
67import { cors } from "hono/cors" ;
78import { BuiltInPriorities , HookRegistrationOptions } from "./types" ;
89import { accessLogMiddleware , errorHandlingMiddleware , requestIdMiddleware } from "../middlewares" ;
@@ -36,20 +37,89 @@ export const ContextStorageHook: HookRegistrationOptions = {
3637 handler : contextStorage ( ) ,
3738}
3839
40+ /**
41+ * Check if an origin is allowed for CORS.
42+ * Allows localhost/127.0.0.1 on the server port, file:// protocol,
43+ * and any additional origins from TARKO_ALLOWED_ORIGINS env var.
44+ */
45+ function isAllowedOrigin ( origin : string , port : number ) : boolean {
46+ const allowedOrigins = new Set ( [
47+ `http://localhost:${ port } ` ,
48+ `http://127.0.0.1:${ port } ` ,
49+ 'file://' ,
50+ ] ) ;
51+
52+ // Support additional origins via environment variable
53+ const extraOrigins = process . env . TARKO_ALLOWED_ORIGINS ;
54+ if ( extraOrigins ) {
55+ for ( const o of extraOrigins . split ( ',' ) ) {
56+ const trimmed = o . trim ( ) ;
57+ if ( trimmed ) {
58+ allowedOrigins . add ( trimmed ) ;
59+ }
60+ }
61+ }
3962
63+ if ( allowedOrigins . has ( origin ) ) {
64+ return true ;
65+ }
4066
67+ // Allow file:// origins (which may have a path suffix)
68+ if ( origin . startsWith ( 'file://' ) ) {
69+ return true ;
70+ }
71+
72+ return false ;
73+ }
74+
75+ /**
76+ * Create a CORS hook with origin whitelist based on server port.
77+ * @param port The server port to allow in CORS origins
78+ */
79+ export function createCorsHook ( port : number ) : HookRegistrationOptions {
80+ return {
81+ id : 'cors' ,
82+ name : 'CORS' ,
83+ priority : BuiltInPriorities . CORS ,
84+ description : 'Cross-Origin Resource Sharing middleware with origin whitelist' ,
85+ handler : cors ( {
86+ origin : ( origin ) => {
87+ if ( ! origin || isAllowedOrigin ( origin , port ) ) {
88+ return origin || '*' ;
89+ }
90+ return null ;
91+ } ,
92+ allowMethods : [ 'GET' , 'POST' , 'PUT' , 'DELETE' , 'OPTIONS' ] ,
93+ allowHeaders : [
94+ 'Content-Type' ,
95+ 'Authorization' ,
96+ 'X-Requested-With' ,
97+ 'X-CSRF-Token' ,
98+ 'x-user-info' ,
99+ 'x-jwt-token' ,
100+ ] ,
101+ credentials : true ,
102+ } ) ,
103+ } ;
104+ }
105+
106+ /**
107+ * @deprecated Use createCorsHook(port) instead for proper origin validation.
108+ * This export uses permissive CORS with ACCESS_ALLOW_ORIGIN env var fallback to '*'.
109+ */
41110export const CorsHook : HookRegistrationOptions = {
42111 id : 'cors' ,
43112 name : 'CORS' ,
44113 priority : BuiltInPriorities . CORS ,
45- description : 'Cross-Origin Resource Sharing middleware' ,
114+ description : 'Cross-Origin Resource Sharing middleware (deprecated: use createCorsHook) ' ,
46115 handler : cors ( {
47116 origin : process . env . ACCESS_ALLOW_ORIGIN || '*' ,
48117 allowMethods : [ 'GET' , 'POST' , 'PUT' , 'DELETE' , 'OPTIONS' ] ,
49118 allowHeaders : [
50119 'Content-Type' ,
51120 'Authorization' ,
52121 'X-Requested-With' ,
122+ 'X-CSRF-Token' ,
53123 'x-user-info' ,
54124 'x-jwt-token' ,
55125 ] ,
@@ -75,3 +145,86 @@ export const AuthHook: HookRegistrationOptions = {
75145 description : 'Authentication and authorization middleware' ,
76146 handler : authMiddleware ,
77147}
148+
149+ // ---- CSRF Token Management ----
150+
151+ const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000 ; // 24 hours
152+ const CSRF_MAX_TOKENS = 1000 ;
153+ const csrfTokenStore = new Map < string , number > ( ) ;
154+
155+ function cleanExpiredCsrfTokens ( ) : void {
156+ const now = Date . now ( ) ;
157+ for ( const [ token , expiry ] of csrfTokenStore ) {
158+ if ( expiry <= now ) {
159+ csrfTokenStore . delete ( token ) ;
160+ }
161+ }
162+ }
163+
164+ export function generateCsrfToken ( ) : string {
165+ if ( csrfTokenStore . size > CSRF_MAX_TOKENS ) {
166+ cleanExpiredCsrfTokens ( ) ;
167+ }
168+ const token = crypto . randomBytes ( 32 ) . toString ( 'hex' ) ;
169+ csrfTokenStore . set ( token , Date . now ( ) + CSRF_TOKEN_EXPIRY_MS ) ;
170+ return token ;
171+ }
172+
173+ function isValidCsrfToken ( token : string ) : boolean {
174+ const expiry = csrfTokenStore . get ( token ) ;
175+ if ( ! expiry ) return false ;
176+ if ( Date . now ( ) > expiry ) {
177+ csrfTokenStore . delete ( token ) ;
178+ return false ;
179+ }
180+ return true ;
181+ }
182+
183+ const SAFE_METHODS = new Set ( [ 'GET' , 'HEAD' , 'OPTIONS' ] ) ;
184+
185+ /**
186+ * Create a CSRF protection hook.
187+ * Validates X-CSRF-Token header on mutation requests (POST/PUT/DELETE).
188+ */
189+ export function createCsrfProtectionHook ( ) : HookRegistrationOptions {
190+ return {
191+ id : 'csrf-protection' ,
192+ name : 'CSRF Protection' ,
193+ priority : BuiltInPriorities . AUTH - 10 , // Just before auth
194+ description : 'CSRF token validation for mutation requests' ,
195+ handler : async ( c , next ) => {
196+ if ( SAFE_METHODS . has ( c . req . method ) ) {
197+ await next ( ) ;
198+ return ;
199+ }
200+
201+ const token = c . req . header ( 'X-CSRF-Token' ) ;
202+ if ( ! token || ! isValidCsrfToken ( token ) ) {
203+ return c . json ( {
204+ error : 'CSRF token missing or invalid' ,
205+ message : 'A valid CSRF token is required for mutation requests. Obtain one via GET /api/v1/csrf-token.' ,
206+ } , 403 ) ;
207+ }
208+
209+ await next ( ) ;
210+ } ,
211+ } ;
212+ }
213+
214+ /**
215+ * Security headers hook.
216+ * Adds standard security response headers.
217+ */
218+ export const SecurityHeadersHook : HookRegistrationOptions = {
219+ id : 'security-headers' ,
220+ name : 'Security Headers' ,
221+ priority : BuiltInPriorities . CORS + 10 , // Before CORS
222+ description : 'Adds security response headers' ,
223+ handler : async ( c , next ) => {
224+ await next ( ) ;
225+ c . header ( 'X-Content-Type-Options' , 'nosniff' ) ;
226+ c . header ( 'X-Frame-Options' , 'DENY' ) ;
227+ c . header ( 'X-XSS-Protection' , '1; mode=block' ) ;
228+ c . header ( 'Referrer-Policy' , 'strict-origin-when-cross-origin' ) ;
229+ } ,
230+ } ;
0 commit comments