create-phoenixjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +196 -0
- package/package.json +31 -0
- package/template/README.md +62 -0
- package/template/app/controllers/ExampleController.ts +61 -0
- package/template/artisan +2 -0
- package/template/bootstrap/app.ts +44 -0
- package/template/bunfig.toml +7 -0
- package/template/config/database.ts +25 -0
- package/template/config/plugins.ts +7 -0
- package/template/config/security.ts +158 -0
- package/template/framework/cli/Command.ts +17 -0
- package/template/framework/cli/ConsoleApplication.ts +55 -0
- package/template/framework/cli/artisan.ts +16 -0
- package/template/framework/cli/commands/MakeControllerCommand.ts +41 -0
- package/template/framework/cli/commands/MakeMiddlewareCommand.ts +41 -0
- package/template/framework/cli/commands/MakeModelCommand.ts +36 -0
- package/template/framework/cli/commands/MakeValidatorCommand.ts +42 -0
- package/template/framework/controller/Controller.ts +222 -0
- package/template/framework/core/Application.ts +208 -0
- package/template/framework/core/Container.ts +100 -0
- package/template/framework/core/Kernel.ts +297 -0
- package/template/framework/database/DatabaseAdapter.ts +18 -0
- package/template/framework/database/PrismaAdapter.ts +65 -0
- package/template/framework/database/SqlAdapter.ts +117 -0
- package/template/framework/gateway/Gateway.ts +109 -0
- package/template/framework/gateway/GatewayManager.ts +150 -0
- package/template/framework/gateway/WebSocketAdapter.ts +159 -0
- package/template/framework/gateway/WebSocketGateway.ts +182 -0
- package/template/framework/http/Request.ts +608 -0
- package/template/framework/http/Response.ts +525 -0
- package/template/framework/http/Server.ts +161 -0
- package/template/framework/http/UploadedFile.ts +145 -0
- package/template/framework/middleware/Middleware.ts +50 -0
- package/template/framework/middleware/Pipeline.ts +89 -0
- package/template/framework/plugin/Plugin.ts +26 -0
- package/template/framework/plugin/PluginManager.ts +61 -0
- package/template/framework/routing/RouteRegistry.ts +185 -0
- package/template/framework/routing/Router.ts +280 -0
- package/template/framework/security/CorsMiddleware.ts +151 -0
- package/template/framework/security/CsrfMiddleware.ts +121 -0
- package/template/framework/security/HelmetMiddleware.ts +138 -0
- package/template/framework/security/InputSanitizerMiddleware.ts +134 -0
- package/template/framework/security/RateLimiterMiddleware.ts +189 -0
- package/template/framework/security/SecurityManager.ts +128 -0
- package/template/framework/validation/Validator.ts +482 -0
- package/template/package.json +24 -0
- package/template/routes/api.ts +56 -0
- package/template/server.ts +29 -0
- package/template/tsconfig.json +49 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PhoenixJS - Router
|
|
3
|
+
*
|
|
4
|
+
* Laravel-style router with fluent API, route groups, and middleware support.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { RouteRegistry, type HttpMethod, type RouteHandler, type Route, type RouteMatch } from '@framework/routing/RouteRegistry';
|
|
8
|
+
import type { MiddlewareResolvable } from '@framework/middleware/Middleware';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Route group options
|
|
12
|
+
*/
|
|
13
|
+
export interface RouteGroupOptions {
|
|
14
|
+
/** Prefix to add to all routes in the group */
|
|
15
|
+
prefix?: string;
|
|
16
|
+
/** Middleware to apply to all routes in the group */
|
|
17
|
+
middleware?: MiddlewareResolvable[];
|
|
18
|
+
/** Name prefix for named routes */
|
|
19
|
+
as?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Context for the current route group
|
|
24
|
+
*/
|
|
25
|
+
interface GroupContext {
|
|
26
|
+
prefix: string;
|
|
27
|
+
middleware: MiddlewareResolvable[];
|
|
28
|
+
namePrefix: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Router class
|
|
33
|
+
*
|
|
34
|
+
* Provides a fluent API for defining routes with Laravel-like syntax.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* Router.get('/users', 'UserController@index');
|
|
39
|
+
* Router.post('/users', 'UserController@store').middleware('auth');
|
|
40
|
+
*
|
|
41
|
+
* Router.group({ prefix: '/api', middleware: ['auth'] }, () => {
|
|
42
|
+
* Router.get('/users/:id', 'UserController@show');
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export class Router {
|
|
47
|
+
private static instance: Router | null = null;
|
|
48
|
+
private registry: RouteRegistry;
|
|
49
|
+
private groupStack: GroupContext[] = [];
|
|
50
|
+
private lastRoute: Route | null = null;
|
|
51
|
+
private middlewareMap: Map<string, MiddlewareResolvable> = new Map();
|
|
52
|
+
|
|
53
|
+
private constructor() {
|
|
54
|
+
this.registry = new RouteRegistry();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the singleton router instance
|
|
59
|
+
*/
|
|
60
|
+
static getInstance(): Router {
|
|
61
|
+
if (!Router.instance) {
|
|
62
|
+
Router.instance = new Router();
|
|
63
|
+
}
|
|
64
|
+
return Router.instance;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reset the router (for testing)
|
|
69
|
+
*/
|
|
70
|
+
static reset(): void {
|
|
71
|
+
if (Router.instance) {
|
|
72
|
+
Router.instance.registry.clear();
|
|
73
|
+
Router.instance.groupStack = [];
|
|
74
|
+
Router.instance.lastRoute = null;
|
|
75
|
+
Router.instance.middlewareMap.clear();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Register a route alias for middleware
|
|
81
|
+
*/
|
|
82
|
+
static aliasMiddleware(name: string, middleware: MiddlewareResolvable): void {
|
|
83
|
+
Router.getInstance().middlewareMap.set(name, middleware);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* GET route
|
|
88
|
+
*/
|
|
89
|
+
static get(path: string, handler: RouteHandler): typeof Router {
|
|
90
|
+
return Router.addRoute('GET', path, handler);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST route
|
|
95
|
+
*/
|
|
96
|
+
static post(path: string, handler: RouteHandler): typeof Router {
|
|
97
|
+
return Router.addRoute('POST', path, handler);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* PUT route
|
|
102
|
+
*/
|
|
103
|
+
static put(path: string, handler: RouteHandler): typeof Router {
|
|
104
|
+
return Router.addRoute('PUT', path, handler);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* PATCH route
|
|
109
|
+
*/
|
|
110
|
+
static patch(path: string, handler: RouteHandler): typeof Router {
|
|
111
|
+
return Router.addRoute('PATCH', path, handler);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* DELETE route
|
|
116
|
+
*/
|
|
117
|
+
static delete(path: string, handler: RouteHandler): typeof Router {
|
|
118
|
+
return Router.addRoute('DELETE', path, handler);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* OPTIONS route
|
|
123
|
+
*/
|
|
124
|
+
static options(path: string, handler: RouteHandler): typeof Router {
|
|
125
|
+
return Router.addRoute('OPTIONS', path, handler);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Match multiple HTTP methods
|
|
130
|
+
*/
|
|
131
|
+
static match(methods: HttpMethod[], path: string, handler: RouteHandler): typeof Router {
|
|
132
|
+
const router = Router.getInstance();
|
|
133
|
+
for (const method of methods) {
|
|
134
|
+
Router.addRoute(method, path, handler);
|
|
135
|
+
}
|
|
136
|
+
return Router;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Match any HTTP method
|
|
141
|
+
*/
|
|
142
|
+
static any(path: string, handler: RouteHandler): typeof Router {
|
|
143
|
+
return Router.match(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], path, handler);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create a route group
|
|
148
|
+
*/
|
|
149
|
+
static group(options: RouteGroupOptions, callback: () => void): void {
|
|
150
|
+
const router = Router.getInstance();
|
|
151
|
+
|
|
152
|
+
// Get current context
|
|
153
|
+
const currentPrefix = router.getCurrentPrefix();
|
|
154
|
+
const currentMiddleware = router.getCurrentMiddleware();
|
|
155
|
+
const currentNamePrefix = router.getCurrentNamePrefix();
|
|
156
|
+
|
|
157
|
+
// Push new context
|
|
158
|
+
router.groupStack.push({
|
|
159
|
+
prefix: currentPrefix + (options.prefix || ''),
|
|
160
|
+
middleware: [...currentMiddleware, ...(options.middleware || [])],
|
|
161
|
+
namePrefix: currentNamePrefix + (options.as || ''),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Execute callback
|
|
165
|
+
callback();
|
|
166
|
+
|
|
167
|
+
// Pop context
|
|
168
|
+
router.groupStack.pop();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Add middleware to the last registered route
|
|
173
|
+
* For fluent API: Router.get('/').middleware('auth')
|
|
174
|
+
*/
|
|
175
|
+
static middleware(...middleware: MiddlewareResolvable[]): typeof Router {
|
|
176
|
+
const router = Router.getInstance();
|
|
177
|
+
if (router.lastRoute) {
|
|
178
|
+
router.lastRoute.middleware.push(...middleware);
|
|
179
|
+
}
|
|
180
|
+
return Router;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Name the last registered route
|
|
185
|
+
* For fluent API: Router.get('/').name('home')
|
|
186
|
+
*/
|
|
187
|
+
static name(name: string): typeof Router {
|
|
188
|
+
const router = Router.getInstance();
|
|
189
|
+
if (router.lastRoute) {
|
|
190
|
+
const prefix = router.getCurrentNamePrefix();
|
|
191
|
+
router.lastRoute.name = prefix + name;
|
|
192
|
+
}
|
|
193
|
+
return Router;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve a route from method and path
|
|
198
|
+
*/
|
|
199
|
+
static resolve(method: string, path: string): RouteMatch | null {
|
|
200
|
+
return Router.getInstance().registry.match(method, path);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get all registered routes
|
|
205
|
+
*/
|
|
206
|
+
static routes(): Route[] {
|
|
207
|
+
return Router.getInstance().registry.all();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Find a route by name
|
|
212
|
+
*/
|
|
213
|
+
static route(name: string): Route | undefined {
|
|
214
|
+
return Router.getInstance().registry.findByName(name);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get route count
|
|
219
|
+
*/
|
|
220
|
+
static count(): number {
|
|
221
|
+
return Router.getInstance().registry.count();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Resolve middleware alias to actual middleware
|
|
226
|
+
*/
|
|
227
|
+
static resolveMiddleware(name: string): MiddlewareResolvable | undefined {
|
|
228
|
+
return Router.getInstance().middlewareMap.get(name);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get registered middleware map
|
|
233
|
+
*/
|
|
234
|
+
static getMiddlewareMap(): Map<string, MiddlewareResolvable> {
|
|
235
|
+
return Router.getInstance().middlewareMap;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Add a route to the registry
|
|
240
|
+
*/
|
|
241
|
+
private static addRoute(method: HttpMethod, path: string, handler: RouteHandler): typeof Router {
|
|
242
|
+
const router = Router.getInstance();
|
|
243
|
+
|
|
244
|
+
// Apply group prefix
|
|
245
|
+
const fullPath = router.getCurrentPrefix() + path;
|
|
246
|
+
|
|
247
|
+
// Apply group middleware
|
|
248
|
+
const middleware = [...router.getCurrentMiddleware()];
|
|
249
|
+
|
|
250
|
+
// Register the route
|
|
251
|
+
router.lastRoute = router.registry.add(method, fullPath, handler, middleware);
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
return Router;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get current prefix from group stack
|
|
259
|
+
*/
|
|
260
|
+
private getCurrentPrefix(): string {
|
|
261
|
+
if (this.groupStack.length === 0) return '';
|
|
262
|
+
return this.groupStack[this.groupStack.length - 1].prefix;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get current middleware from group stack
|
|
267
|
+
*/
|
|
268
|
+
private getCurrentMiddleware(): MiddlewareResolvable[] {
|
|
269
|
+
if (this.groupStack.length === 0) return [];
|
|
270
|
+
return [...this.groupStack[this.groupStack.length - 1].middleware];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get current name prefix from group stack
|
|
275
|
+
*/
|
|
276
|
+
private getCurrentNamePrefix(): string {
|
|
277
|
+
if (this.groupStack.length === 0) return '';
|
|
278
|
+
return this.groupStack[this.groupStack.length - 1].namePrefix;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PhoenixJS - CORS Middleware
|
|
3
|
+
*
|
|
4
|
+
* Handles Cross-Origin Resource Sharing (CORS) headers and preflight requests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Middleware, NextFunction } from '@framework/middleware/Middleware';
|
|
8
|
+
import type { FrameworkRequest } from '@framework/http/Request';
|
|
9
|
+
import type { CorsConfig } from '@/config/security';
|
|
10
|
+
|
|
11
|
+
export class CorsMiddleware implements Middleware {
|
|
12
|
+
constructor(private config: CorsConfig) { }
|
|
13
|
+
|
|
14
|
+
async handle(request: FrameworkRequest, next: NextFunction): Promise<Response> {
|
|
15
|
+
const origin = request.header('Origin') || '';
|
|
16
|
+
|
|
17
|
+
// Handle preflight requests
|
|
18
|
+
if (request.method() === 'OPTIONS') {
|
|
19
|
+
return this.handlePreflight(request, origin);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Process the request through the pipeline
|
|
23
|
+
const response = await next(request);
|
|
24
|
+
|
|
25
|
+
// Add CORS headers to the response
|
|
26
|
+
return this.addCorsHeaders(response, origin);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Handle preflight (OPTIONS) requests
|
|
31
|
+
*/
|
|
32
|
+
private handlePreflight(request: FrameworkRequest, origin: string): Response {
|
|
33
|
+
const headers: Record<string, string> = {};
|
|
34
|
+
|
|
35
|
+
// Check if origin is allowed
|
|
36
|
+
if (this.isOriginAllowed(origin)) {
|
|
37
|
+
headers['Access-Control-Allow-Origin'] = this.getAllowedOrigin(origin);
|
|
38
|
+
|
|
39
|
+
if (this.config.credentials) {
|
|
40
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Set allowed methods
|
|
45
|
+
headers['Access-Control-Allow-Methods'] = this.config.methods.join(', ');
|
|
46
|
+
|
|
47
|
+
// Set allowed headers
|
|
48
|
+
const requestedHeaders = request.header('Access-Control-Request-Headers');
|
|
49
|
+
if (requestedHeaders) {
|
|
50
|
+
headers['Access-Control-Allow-Headers'] = this.config.allowedHeaders.join(', ');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Set max age
|
|
54
|
+
headers['Access-Control-Max-Age'] = this.config.maxAge.toString();
|
|
55
|
+
|
|
56
|
+
return new Response(null, {
|
|
57
|
+
status: 204,
|
|
58
|
+
headers,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Add CORS headers to a response
|
|
64
|
+
*/
|
|
65
|
+
private addCorsHeaders(response: Response, origin: string): Response {
|
|
66
|
+
const headers = new Headers(response.headers);
|
|
67
|
+
|
|
68
|
+
// Check if origin is allowed
|
|
69
|
+
if (this.isOriginAllowed(origin)) {
|
|
70
|
+
headers.set('Access-Control-Allow-Origin', this.getAllowedOrigin(origin));
|
|
71
|
+
|
|
72
|
+
if (this.config.credentials) {
|
|
73
|
+
headers.set('Access-Control-Allow-Credentials', 'true');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Set exposed headers
|
|
78
|
+
if (this.config.exposedHeaders.length > 0) {
|
|
79
|
+
headers.set('Access-Control-Expose-Headers', this.config.exposedHeaders.join(', '));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return new Response(response.body, {
|
|
83
|
+
status: response.status,
|
|
84
|
+
statusText: response.statusText,
|
|
85
|
+
headers,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if an origin is allowed
|
|
91
|
+
*/
|
|
92
|
+
private isOriginAllowed(origin: string): boolean {
|
|
93
|
+
const configOrigin = this.config.origin;
|
|
94
|
+
|
|
95
|
+
// Wildcard allows all
|
|
96
|
+
if (configOrigin === '*') {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// String match
|
|
101
|
+
if (typeof configOrigin === 'string') {
|
|
102
|
+
return origin === configOrigin;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Array of origins
|
|
106
|
+
if (Array.isArray(configOrigin)) {
|
|
107
|
+
return configOrigin.includes(origin);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Regex match
|
|
111
|
+
if (configOrigin instanceof RegExp) {
|
|
112
|
+
return configOrigin.test(origin);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Function check
|
|
116
|
+
if (typeof configOrigin === 'function') {
|
|
117
|
+
return configOrigin(origin);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get the allowed origin value for the header
|
|
125
|
+
*/
|
|
126
|
+
private getAllowedOrigin(origin: string): string {
|
|
127
|
+
// If wildcard and no credentials, return *
|
|
128
|
+
if (this.config.origin === '*' && !this.config.credentials) {
|
|
129
|
+
return '*';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Otherwise return the requesting origin (if allowed)
|
|
133
|
+
return origin;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a CORS middleware instance with the given config
|
|
139
|
+
*/
|
|
140
|
+
export function cors(config: Partial<CorsConfig> = {}): CorsMiddleware {
|
|
141
|
+
const defaultConfig: CorsConfig = {
|
|
142
|
+
origin: '*',
|
|
143
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
144
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
|
145
|
+
exposedHeaders: [],
|
|
146
|
+
credentials: false,
|
|
147
|
+
maxAge: 86400,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return new CorsMiddleware({ ...defaultConfig, ...config });
|
|
151
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PhoenixJS - CSRF Middleware
|
|
3
|
+
*
|
|
4
|
+
* Provides Cross-Site Request Forgery protection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Middleware, NextFunction } from '@framework/middleware/Middleware';
|
|
8
|
+
import type { FrameworkRequest } from '@framework/http/Request';
|
|
9
|
+
import type { CsrfConfig } from '@/config/security';
|
|
10
|
+
import { FrameworkResponse } from '@framework/http/Response';
|
|
11
|
+
|
|
12
|
+
export class CsrfMiddleware implements Middleware {
|
|
13
|
+
private tokens: Map<string, { token: string; expires: number }> = new Map();
|
|
14
|
+
|
|
15
|
+
constructor(private config: CsrfConfig) { }
|
|
16
|
+
|
|
17
|
+
async handle(request: FrameworkRequest, next: NextFunction): Promise<Response> {
|
|
18
|
+
// Skip if CSRF is disabled
|
|
19
|
+
if (!this.config.enabled) {
|
|
20
|
+
return next(request);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Skip safe methods (GET, HEAD, OPTIONS)
|
|
24
|
+
if (this.config.safeMethods.includes(request.method())) {
|
|
25
|
+
const response = await next(request);
|
|
26
|
+
return this.attachCsrfCookie(response, request);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Validate CSRF token for non-safe methods
|
|
30
|
+
if (!this.validateToken(request)) {
|
|
31
|
+
return FrameworkResponse.json(
|
|
32
|
+
{
|
|
33
|
+
error: true,
|
|
34
|
+
message: 'CSRF token mismatch',
|
|
35
|
+
},
|
|
36
|
+
403
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return next(request);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate the CSRF token from the request
|
|
45
|
+
*/
|
|
46
|
+
private validateToken(request: FrameworkRequest): boolean {
|
|
47
|
+
// Get token from header
|
|
48
|
+
const headerToken = request.header(this.config.headerName);
|
|
49
|
+
|
|
50
|
+
// Get token from cookie
|
|
51
|
+
const cookieToken = request.cookie(this.config.cookieName);
|
|
52
|
+
|
|
53
|
+
// Both must exist and match
|
|
54
|
+
if (!headerToken || !cookieToken) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Tokens must match
|
|
59
|
+
return headerToken === cookieToken;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Attach CSRF cookie to response
|
|
64
|
+
*/
|
|
65
|
+
private attachCsrfCookie(response: Response, request: FrameworkRequest): Response {
|
|
66
|
+
// Check if token already exists in cookie
|
|
67
|
+
const existingToken = request.cookie(this.config.cookieName);
|
|
68
|
+
|
|
69
|
+
// Generate or use existing token
|
|
70
|
+
const token = existingToken || this.generateToken();
|
|
71
|
+
|
|
72
|
+
// Clone response with CSRF cookie
|
|
73
|
+
const headers = new Headers(response.headers);
|
|
74
|
+
const cookieValue = `${this.config.cookieName}=${token}; Path=/; SameSite=Strict`;
|
|
75
|
+
headers.append('Set-Cookie', cookieValue);
|
|
76
|
+
|
|
77
|
+
return new Response(response.body, {
|
|
78
|
+
status: response.status,
|
|
79
|
+
statusText: response.statusText,
|
|
80
|
+
headers,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Generate a cryptographically secure random token
|
|
86
|
+
*/
|
|
87
|
+
generateToken(): string {
|
|
88
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
89
|
+
const array = new Uint8Array(this.config.tokenLength);
|
|
90
|
+
crypto.getRandomValues(array);
|
|
91
|
+
|
|
92
|
+
let token = '';
|
|
93
|
+
for (let i = 0; i < this.config.tokenLength; i++) {
|
|
94
|
+
token += chars[array[i] % chars.length];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return token;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a token for testing
|
|
102
|
+
*/
|
|
103
|
+
createTestToken(): string {
|
|
104
|
+
return this.generateToken();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a CSRF middleware instance with the given config
|
|
110
|
+
*/
|
|
111
|
+
export function csrf(config: Partial<CsrfConfig> = {}): CsrfMiddleware {
|
|
112
|
+
const defaultConfig: CsrfConfig = {
|
|
113
|
+
enabled: true,
|
|
114
|
+
cookieName: 'XSRF-TOKEN',
|
|
115
|
+
headerName: 'X-CSRF-Token',
|
|
116
|
+
tokenLength: 32,
|
|
117
|
+
safeMethods: ['GET', 'HEAD', 'OPTIONS'],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return new CsrfMiddleware({ ...defaultConfig, ...config });
|
|
121
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PhoenixJS - Helmet Middleware
|
|
3
|
+
*
|
|
4
|
+
* Sets security-related HTTP headers (inspired by Helmet.js).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Middleware, NextFunction } from '@framework/middleware/Middleware';
|
|
8
|
+
import type { FrameworkRequest } from '@framework/http/Request';
|
|
9
|
+
import type { HelmetConfig } from '@/config/security';
|
|
10
|
+
|
|
11
|
+
export class HelmetMiddleware implements Middleware {
|
|
12
|
+
constructor(private config: HelmetConfig) { }
|
|
13
|
+
|
|
14
|
+
async handle(request: FrameworkRequest, next: NextFunction): Promise<Response> {
|
|
15
|
+
// Process the request through the pipeline
|
|
16
|
+
const response = await next(request);
|
|
17
|
+
|
|
18
|
+
// Add security headers to the response
|
|
19
|
+
return this.addSecurityHeaders(response);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Add security headers to a response
|
|
24
|
+
*/
|
|
25
|
+
private addSecurityHeaders(response: Response): Response {
|
|
26
|
+
const headers = new Headers(response.headers);
|
|
27
|
+
|
|
28
|
+
// X-Frame-Options
|
|
29
|
+
if (this.config.frameOptions) {
|
|
30
|
+
headers.set('X-Frame-Options', this.config.frameOptions);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// X-Content-Type-Options
|
|
34
|
+
if (this.config.contentTypeOptions) {
|
|
35
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// X-XSS-Protection
|
|
39
|
+
if (this.config.xssProtection) {
|
|
40
|
+
headers.set('X-XSS-Protection', '1; mode=block');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Strict-Transport-Security (HSTS)
|
|
44
|
+
if (this.config.hsts.enabled) {
|
|
45
|
+
let hstsValue = `max-age=${this.config.hsts.maxAge}`;
|
|
46
|
+
if (this.config.hsts.includeSubDomains) {
|
|
47
|
+
hstsValue += '; includeSubDomains';
|
|
48
|
+
}
|
|
49
|
+
if (this.config.hsts.preload) {
|
|
50
|
+
hstsValue += '; preload';
|
|
51
|
+
}
|
|
52
|
+
headers.set('Strict-Transport-Security', hstsValue);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Content-Security-Policy
|
|
56
|
+
if (this.config.contentSecurityPolicy.enabled) {
|
|
57
|
+
const csp = this.buildCsp(this.config.contentSecurityPolicy.directives);
|
|
58
|
+
headers.set('Content-Security-Policy', csp);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Referrer-Policy
|
|
62
|
+
if (this.config.referrerPolicy) {
|
|
63
|
+
headers.set('Referrer-Policy', this.config.referrerPolicy);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// X-Permitted-Cross-Domain-Policies
|
|
67
|
+
if (this.config.crossDomainPolicy) {
|
|
68
|
+
headers.set('X-Permitted-Cross-Domain-Policies', this.config.crossDomainPolicy);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// X-Download-Options (IE)
|
|
72
|
+
if (this.config.downloadOptions) {
|
|
73
|
+
headers.set('X-Download-Options', 'noopen');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return new Response(response.body, {
|
|
77
|
+
status: response.status,
|
|
78
|
+
statusText: response.statusText,
|
|
79
|
+
headers,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build Content-Security-Policy header value
|
|
85
|
+
*/
|
|
86
|
+
private buildCsp(directives: Record<string, string | string[]>): string {
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
|
|
89
|
+
for (const [directive, value] of Object.entries(directives)) {
|
|
90
|
+
const directiveName = this.camelToKebab(directive);
|
|
91
|
+
const directiveValue = Array.isArray(value) ? value.join(' ') : value;
|
|
92
|
+
parts.push(`${directiveName} ${directiveValue}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return parts.join('; ');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convert camelCase to kebab-case
|
|
100
|
+
*/
|
|
101
|
+
private camelToKebab(str: string): string {
|
|
102
|
+
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a Helmet middleware instance with the given config
|
|
108
|
+
*/
|
|
109
|
+
export function helmet(config: Partial<HelmetConfig> = {}): HelmetMiddleware {
|
|
110
|
+
const defaultConfig: HelmetConfig = {
|
|
111
|
+
frameOptions: 'DENY',
|
|
112
|
+
contentTypeOptions: true,
|
|
113
|
+
xssProtection: true,
|
|
114
|
+
hsts: {
|
|
115
|
+
enabled: true,
|
|
116
|
+
maxAge: 31536000,
|
|
117
|
+
includeSubDomains: true,
|
|
118
|
+
preload: false,
|
|
119
|
+
},
|
|
120
|
+
contentSecurityPolicy: {
|
|
121
|
+
enabled: false,
|
|
122
|
+
directives: {},
|
|
123
|
+
},
|
|
124
|
+
referrerPolicy: 'no-referrer',
|
|
125
|
+
crossDomainPolicy: 'none',
|
|
126
|
+
downloadOptions: true,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return new HelmetMiddleware({
|
|
130
|
+
...defaultConfig,
|
|
131
|
+
...config,
|
|
132
|
+
hsts: { ...defaultConfig.hsts, ...(config.hsts || {}) },
|
|
133
|
+
contentSecurityPolicy: {
|
|
134
|
+
...defaultConfig.contentSecurityPolicy,
|
|
135
|
+
...(config.contentSecurityPolicy || {}),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|