@xexr/blog-platform 1.0.7 → 1.0.8
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/CHANGELOG.md +7 -0
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +155 -113
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +59 -69
- package/dist/index.d.ts +59 -69
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +719 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +608 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +29 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +256 -0
- package/dist/types/index.d.mts +18 -1
- package/dist/types/index.d.ts +18 -1
- package/dist/types/index.ts +16 -0
- package/package.json +14 -9
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting for blog platform API endpoints
|
|
3
|
+
*
|
|
4
|
+
* This implements rate limiting using Upstash Redis to prevent abuse
|
|
5
|
+
* and ensure fair usage across all API consumers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Ratelimit } from "@upstash/ratelimit";
|
|
9
|
+
import { Redis } from "@upstash/redis";
|
|
10
|
+
import type { PublishResult } from "@/types/contracts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rate limit configuration interface
|
|
14
|
+
* Allows customers to customize rate limiting behavior
|
|
15
|
+
*/
|
|
16
|
+
export interface RateLimitConfig {
|
|
17
|
+
/** Upstash Redis REST URL */
|
|
18
|
+
redisUrl: string;
|
|
19
|
+
|
|
20
|
+
/** Upstash Redis REST token */
|
|
21
|
+
redisToken: string;
|
|
22
|
+
|
|
23
|
+
/** Requests per minute limit (default: 5) */
|
|
24
|
+
requestsPerMinute?: number;
|
|
25
|
+
|
|
26
|
+
/** Redis key prefix for rate limiting (default: "blog-platform-ratelimit") */
|
|
27
|
+
keyPrefix?: string;
|
|
28
|
+
|
|
29
|
+
/** Enable analytics (default: true) */
|
|
30
|
+
analytics?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rate limit result
|
|
35
|
+
*/
|
|
36
|
+
export interface RateLimitResult {
|
|
37
|
+
/** Whether the request is allowed */
|
|
38
|
+
success: boolean;
|
|
39
|
+
|
|
40
|
+
/** Number of requests remaining in current window */
|
|
41
|
+
remaining: number;
|
|
42
|
+
|
|
43
|
+
/** When the rate limit window resets (Unix timestamp) */
|
|
44
|
+
reset: number;
|
|
45
|
+
|
|
46
|
+
/** Total requests allowed per window */
|
|
47
|
+
limit: number;
|
|
48
|
+
|
|
49
|
+
/** Current request count in window */
|
|
50
|
+
count: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Rate limiter instance
|
|
55
|
+
*/
|
|
56
|
+
type GlobalWithRateLimiter = typeof globalThis & {
|
|
57
|
+
__BLOG_PLATFORM_RATE_LIMITER__?: Ratelimit | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function getRateLimiter(): Ratelimit | null {
|
|
61
|
+
const globalRef = globalThis as GlobalWithRateLimiter;
|
|
62
|
+
return globalRef.__BLOG_PLATFORM_RATE_LIMITER__ ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setRateLimiter(instance: Ratelimit | null): void {
|
|
66
|
+
const globalRef = globalThis as GlobalWithRateLimiter;
|
|
67
|
+
globalRef.__BLOG_PLATFORM_RATE_LIMITER__ = instance;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Configure the rate limiter
|
|
72
|
+
* This must be called during app initialization
|
|
73
|
+
*/
|
|
74
|
+
export function configureRateLimit(config: RateLimitConfig): void {
|
|
75
|
+
const {
|
|
76
|
+
redisUrl,
|
|
77
|
+
redisToken,
|
|
78
|
+
requestsPerMinute = 5,
|
|
79
|
+
keyPrefix = "blog-platform-ratelimit",
|
|
80
|
+
analytics = true,
|
|
81
|
+
} = config;
|
|
82
|
+
|
|
83
|
+
const redis = new Redis({
|
|
84
|
+
url: redisUrl,
|
|
85
|
+
token: redisToken,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const limiter = new Ratelimit({
|
|
89
|
+
redis,
|
|
90
|
+
analytics,
|
|
91
|
+
prefix: keyPrefix,
|
|
92
|
+
// 5 requests per minute sliding window
|
|
93
|
+
limiter: Ratelimit.slidingWindow(requestsPerMinute, "1m"),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
setRateLimiter(limiter);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Auto-configure rate limiting from environment variables
|
|
101
|
+
* Uses UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN
|
|
102
|
+
*/
|
|
103
|
+
export function autoConfigureRateLimit(): void {
|
|
104
|
+
const redisUrl = process.env.UPSTASH_REDIS_REST_URL;
|
|
105
|
+
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
106
|
+
|
|
107
|
+
if (!redisUrl || !redisToken) {
|
|
108
|
+
console.warn("Blog platform: No Upstash Redis configuration found. Rate limiting disabled.");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
configureRateLimit({
|
|
113
|
+
redisUrl,
|
|
114
|
+
redisToken,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
console.log("Blog platform: Rate limiting configured (5 requests per minute per token)");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check rate limit for a given identifier (typically an auth token)
|
|
122
|
+
* @param identifier - Unique identifier (usually the authentication token)
|
|
123
|
+
* @returns Rate limit result
|
|
124
|
+
*/
|
|
125
|
+
export async function checkRateLimit(identifier: string): Promise<RateLimitResult> {
|
|
126
|
+
const limiter = getRateLimiter();
|
|
127
|
+
if (!limiter) {
|
|
128
|
+
// Rate limiting not configured - allow all requests
|
|
129
|
+
console.warn("Blog platform: Rate limiter not configured. All requests allowed.");
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
remaining: 999,
|
|
133
|
+
reset: Date.now() + 60000,
|
|
134
|
+
limit: 5,
|
|
135
|
+
count: 0,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const result = await limiter.limit(identifier);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
success: result.success,
|
|
144
|
+
remaining: result.remaining,
|
|
145
|
+
reset: result.reset,
|
|
146
|
+
limit: result.limit,
|
|
147
|
+
count: result.limit - result.remaining,
|
|
148
|
+
};
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error("Blog platform: Rate limit check failed:", error);
|
|
151
|
+
// On error, allow the request but log the issue
|
|
152
|
+
return {
|
|
153
|
+
success: true,
|
|
154
|
+
remaining: 0,
|
|
155
|
+
reset: Date.now() + 60000,
|
|
156
|
+
limit: 5,
|
|
157
|
+
count: 0,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create a rate limit error response
|
|
164
|
+
* @param rateLimitResult - The rate limit result that failed
|
|
165
|
+
* @param idempotencyKey - Optional idempotency key from request
|
|
166
|
+
* @returns PublishResult with rate limit error
|
|
167
|
+
*/
|
|
168
|
+
export function createRateLimitErrorResponse(
|
|
169
|
+
rateLimitResult: RateLimitResult,
|
|
170
|
+
idempotencyKey = ""
|
|
171
|
+
): PublishResult {
|
|
172
|
+
const resetMinutes = Math.ceil((rateLimitResult.reset - Date.now()) / 60000);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
success: false,
|
|
176
|
+
error: {
|
|
177
|
+
code: "PUB011",
|
|
178
|
+
message: `Rate limit exceeded. You can make ${rateLimitResult.limit} requests per minute. Try again in ${resetMinutes} minute(s).`,
|
|
179
|
+
details: {
|
|
180
|
+
rateLimit: rateLimitResult,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
idempotencyKey,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Rate limiting middleware for API routes
|
|
189
|
+
* @param identifier - Unique identifier for rate limiting (typically auth token)
|
|
190
|
+
* @param idempotencyKey - Optional idempotency key from request
|
|
191
|
+
* @returns Rate limit result or error response
|
|
192
|
+
*/
|
|
193
|
+
export async function enforceRateLimit(
|
|
194
|
+
identifier: string,
|
|
195
|
+
idempotencyKey = ""
|
|
196
|
+
): Promise<{ allowed: true; rateLimitResult: RateLimitResult } | { allowed: false; errorResponse: PublishResult }> {
|
|
197
|
+
const rateLimitResult = await checkRateLimit(identifier);
|
|
198
|
+
|
|
199
|
+
if (!rateLimitResult.success) {
|
|
200
|
+
return {
|
|
201
|
+
allowed: false,
|
|
202
|
+
errorResponse: createRateLimitErrorResponse(rateLimitResult, idempotencyKey),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
allowed: true,
|
|
208
|
+
rateLimitResult,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get rate limit headers for HTTP responses
|
|
214
|
+
* @param rateLimitResult - Rate limit result
|
|
215
|
+
* @returns Headers object for HTTP response
|
|
216
|
+
*/
|
|
217
|
+
export function getRateLimitHeaders(rateLimitResult: RateLimitResult): Record<string, string> {
|
|
218
|
+
return {
|
|
219
|
+
"X-RateLimit-Limit": rateLimitResult.limit.toString(),
|
|
220
|
+
"X-RateLimit-Remaining": rateLimitResult.remaining.toString(),
|
|
221
|
+
"X-RateLimit-Reset": rateLimitResult.reset.toString(),
|
|
222
|
+
"Retry-After": Math.ceil((rateLimitResult.reset - Date.now()) / 1000).toString(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security headers for blog platform API endpoints
|
|
3
|
+
*
|
|
4
|
+
* This provides comprehensive security headers to protect against common
|
|
5
|
+
* web vulnerabilities including XSS, clickjacking, and content injection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NextRequest } from "next/server";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Security headers configuration interface
|
|
12
|
+
*/
|
|
13
|
+
export interface SecurityConfig {
|
|
14
|
+
/** Enable Content Security Policy (default: true) */
|
|
15
|
+
enableCSP?: boolean;
|
|
16
|
+
|
|
17
|
+
/** Custom CSP directives (merges with defaults) */
|
|
18
|
+
cspDirectives?: Record<string, string>;
|
|
19
|
+
|
|
20
|
+
/** Enable X-Frame-Options (default: true) */
|
|
21
|
+
enableFrameOptions?: boolean;
|
|
22
|
+
|
|
23
|
+
/** X-Frame-Options value (default: "DENY") */
|
|
24
|
+
frameOptions?: string;
|
|
25
|
+
|
|
26
|
+
/** Enable X-Content-Type-Options (default: true) */
|
|
27
|
+
enableContentTypeOptions?: boolean;
|
|
28
|
+
|
|
29
|
+
/** Enable X-XSS-Protection (default: true) */
|
|
30
|
+
enableXSSProtection?: boolean;
|
|
31
|
+
|
|
32
|
+
/** Enable Referrer-Policy (default: true) */
|
|
33
|
+
enableReferrerPolicy?: boolean;
|
|
34
|
+
|
|
35
|
+
/** Referrer policy value (default: "strict-origin-when-cross-origin") */
|
|
36
|
+
referrerPolicy?: string;
|
|
37
|
+
|
|
38
|
+
/** Enable Strict-Transport-Security for HTTPS (default: true) */
|
|
39
|
+
enableHSTS?: boolean;
|
|
40
|
+
|
|
41
|
+
/** HSTS max-age in seconds (default: 31536000 = 1 year) */
|
|
42
|
+
hstsMaxAge?: number;
|
|
43
|
+
|
|
44
|
+
/** Include HSTS subdomains (default: true) */
|
|
45
|
+
hstsIncludeSubdomains?: boolean;
|
|
46
|
+
|
|
47
|
+
/** Enable Permissions-Policy (default: true) */
|
|
48
|
+
enablePermissionsPolicy?: boolean;
|
|
49
|
+
|
|
50
|
+
/** Custom permissions policy directives */
|
|
51
|
+
permissionsPolicyDirectives?: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Default security configuration for blog platform APIs
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_SECURITY_CONFIG: Required<SecurityConfig> = {
|
|
58
|
+
enableCSP: true,
|
|
59
|
+
cspDirectives: {
|
|
60
|
+
"default-src": "'self'",
|
|
61
|
+
"script-src": "'self' 'unsafe-inline'", // Allow inline scripts for API responses
|
|
62
|
+
"style-src": "'self' 'unsafe-inline'", // Allow inline styles for blog rendering
|
|
63
|
+
"img-src": "'self' data: https:", // Allow images from self, data URIs, and HTTPS
|
|
64
|
+
"font-src": "'self' https:", // Allow fonts from self and HTTPS
|
|
65
|
+
"connect-src": "'self'", // API connections to same origin
|
|
66
|
+
"media-src": "'self'", // Media from same origin only
|
|
67
|
+
"object-src": "'none'", // Disable object/embed elements
|
|
68
|
+
"base-uri": "'self'", // Restrict base URIs to same origin
|
|
69
|
+
"form-action": "'self'", // Forms can only submit to same origin
|
|
70
|
+
"frame-ancestors": "'none'", // Prevent embedding in frames
|
|
71
|
+
"upgrade-insecure-requests": "", // Upgrade HTTP to HTTPS
|
|
72
|
+
},
|
|
73
|
+
enableFrameOptions: true,
|
|
74
|
+
frameOptions: "DENY",
|
|
75
|
+
enableContentTypeOptions: true,
|
|
76
|
+
enableXSSProtection: true,
|
|
77
|
+
enableReferrerPolicy: true,
|
|
78
|
+
referrerPolicy: "strict-origin-when-cross-origin",
|
|
79
|
+
enableHSTS: true,
|
|
80
|
+
hstsMaxAge: 31536000, // 1 year
|
|
81
|
+
hstsIncludeSubdomains: true,
|
|
82
|
+
enablePermissionsPolicy: true,
|
|
83
|
+
permissionsPolicyDirectives: {
|
|
84
|
+
"camera": "()", // Disable camera access
|
|
85
|
+
"microphone": "()", // Disable microphone access
|
|
86
|
+
"geolocation": "()", // Disable geolocation
|
|
87
|
+
"payment": "()", // Disable payment API
|
|
88
|
+
"usb": "()", // Disable USB access
|
|
89
|
+
"accelerometer": "()", // Disable accelerometer
|
|
90
|
+
"gyroscope": "()", // Disable gyroscope
|
|
91
|
+
"magnetometer": "()", // Disable magnetometer
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get security configuration from environment or defaults
|
|
97
|
+
*/
|
|
98
|
+
function getSecurityConfig(): Required<SecurityConfig> {
|
|
99
|
+
const envCSPDisabled = process.env.BLOG_PLATFORM_DISABLE_CSP === "true";
|
|
100
|
+
const envFrameOptions = process.env.BLOG_PLATFORM_FRAME_OPTIONS;
|
|
101
|
+
const envHSTSDisabled = process.env.BLOG_PLATFORM_DISABLE_HSTS === "true";
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...DEFAULT_SECURITY_CONFIG,
|
|
105
|
+
enableCSP: !envCSPDisabled,
|
|
106
|
+
frameOptions: envFrameOptions ?? DEFAULT_SECURITY_CONFIG.frameOptions,
|
|
107
|
+
enableHSTS: !envHSTSDisabled,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build Content Security Policy header value
|
|
113
|
+
*/
|
|
114
|
+
function buildCSPHeader(directives: Record<string, string>): string {
|
|
115
|
+
return Object.entries(directives)
|
|
116
|
+
.map(([directive, value]) =>
|
|
117
|
+
value ? `${directive} ${value}` : directive
|
|
118
|
+
)
|
|
119
|
+
.join("; ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build Permissions Policy header value
|
|
124
|
+
*/
|
|
125
|
+
function buildPermissionsPolicyHeader(directives: Record<string, string>): string {
|
|
126
|
+
return Object.entries(directives)
|
|
127
|
+
.map(([feature, allowlist]) => `${feature}=${allowlist}`)
|
|
128
|
+
.join(", ");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get security headers for HTTP responses
|
|
133
|
+
*/
|
|
134
|
+
export function getSecurityHeaders(
|
|
135
|
+
request: NextRequest,
|
|
136
|
+
config: SecurityConfig = {}
|
|
137
|
+
): Record<string, string> {
|
|
138
|
+
const securityConfig = { ...getSecurityConfig(), ...config };
|
|
139
|
+
const headers: Record<string, string> = {};
|
|
140
|
+
|
|
141
|
+
// Content Security Policy
|
|
142
|
+
if (securityConfig.enableCSP) {
|
|
143
|
+
const cspDirectives = {
|
|
144
|
+
...securityConfig.cspDirectives,
|
|
145
|
+
...(config.cspDirectives ?? {}),
|
|
146
|
+
};
|
|
147
|
+
headers["Content-Security-Policy"] = buildCSPHeader(cspDirectives);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// X-Frame-Options (prevent clickjacking)
|
|
151
|
+
if (securityConfig.enableFrameOptions) {
|
|
152
|
+
headers["X-Frame-Options"] = securityConfig.frameOptions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// X-Content-Type-Options (prevent MIME sniffing)
|
|
156
|
+
if (securityConfig.enableContentTypeOptions) {
|
|
157
|
+
headers["X-Content-Type-Options"] = "nosniff";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// X-XSS-Protection (legacy XSS protection)
|
|
161
|
+
if (securityConfig.enableXSSProtection) {
|
|
162
|
+
headers["X-XSS-Protection"] = "1; mode=block";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Referrer-Policy (control referrer information)
|
|
166
|
+
if (securityConfig.enableReferrerPolicy) {
|
|
167
|
+
headers["Referrer-Policy"] = securityConfig.referrerPolicy;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Strict-Transport-Security (HTTPS enforcement)
|
|
171
|
+
if (securityConfig.enableHSTS && isHTTPS(request)) {
|
|
172
|
+
const hstsValue = [
|
|
173
|
+
`max-age=${securityConfig.hstsMaxAge}`,
|
|
174
|
+
securityConfig.hstsIncludeSubdomains ? "includeSubDomains" : "",
|
|
175
|
+
].filter(Boolean).join("; ");
|
|
176
|
+
|
|
177
|
+
headers["Strict-Transport-Security"] = hstsValue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Permissions-Policy (feature control)
|
|
181
|
+
if (securityConfig.enablePermissionsPolicy) {
|
|
182
|
+
const permissionsDirectives = {
|
|
183
|
+
...securityConfig.permissionsPolicyDirectives,
|
|
184
|
+
...(config.permissionsPolicyDirectives ?? {}),
|
|
185
|
+
};
|
|
186
|
+
headers["Permissions-Policy"] = buildPermissionsPolicyHeader(permissionsDirectives);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return headers;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Check if request is over HTTPS
|
|
194
|
+
*/
|
|
195
|
+
function isHTTPS(request: NextRequest): boolean {
|
|
196
|
+
// Check protocol from URL
|
|
197
|
+
if (request.url.startsWith("https://")) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check forwarded protocol headers (for proxies/load balancers)
|
|
202
|
+
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
203
|
+
if (forwardedProto === "https") {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check if we're in a secure context (Cloudflare, Vercel, etc.)
|
|
208
|
+
const cfVisitor = request.headers.get("cf-visitor");
|
|
209
|
+
if (cfVisitor?.includes('"scheme":"https"')) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Create security headers optimized for API endpoints
|
|
218
|
+
*/
|
|
219
|
+
export function getAPISecurityHeaders(
|
|
220
|
+
request: NextRequest,
|
|
221
|
+
config: SecurityConfig = {}
|
|
222
|
+
): Record<string, string> {
|
|
223
|
+
// API-specific CSP that's less restrictive for JSON responses
|
|
224
|
+
const apiCSPDirectives = {
|
|
225
|
+
"default-src": "'none'", // APIs don't need default sources
|
|
226
|
+
"frame-ancestors": "'none'", // Prevent embedding
|
|
227
|
+
"base-uri": "'none'", // No base URI needed for APIs
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return getSecurityHeaders(request, {
|
|
231
|
+
...config,
|
|
232
|
+
cspDirectives: {
|
|
233
|
+
...apiCSPDirectives,
|
|
234
|
+
...(config.cspDirectives ?? {}),
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create security headers optimized for blog pages
|
|
241
|
+
*/
|
|
242
|
+
export function getBlogSecurityHeaders(
|
|
243
|
+
request: NextRequest,
|
|
244
|
+
config: SecurityConfig = {}
|
|
245
|
+
): Record<string, string> {
|
|
246
|
+
// Blog-specific CSP that allows images, fonts, and styles
|
|
247
|
+
const blogCSPDirectives = {
|
|
248
|
+
"default-src": "'self'",
|
|
249
|
+
"script-src": "'self' 'unsafe-inline'",
|
|
250
|
+
"style-src": "'self' 'unsafe-inline' https:",
|
|
251
|
+
"img-src": "'self' data: https:",
|
|
252
|
+
"font-src": "'self' https:",
|
|
253
|
+
"connect-src": "'self'",
|
|
254
|
+
"frame-src": "'none'", // No iframes in blog posts
|
|
255
|
+
"object-src": "'none'", // No plugins/objects
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return getSecurityHeaders(request, {
|
|
259
|
+
...config,
|
|
260
|
+
frameOptions: "SAMEORIGIN", // Allow embedding from same origin
|
|
261
|
+
cspDirectives: {
|
|
262
|
+
...blogCSPDirectives,
|
|
263
|
+
...(config.cspDirectives ?? {}),
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Security header presets for different use cases
|
|
270
|
+
*/
|
|
271
|
+
export const SECURITY_PRESETS = {
|
|
272
|
+
/** Strict security for API endpoints */
|
|
273
|
+
API: {
|
|
274
|
+
enableCSP: true,
|
|
275
|
+
frameOptions: "DENY",
|
|
276
|
+
cspDirectives: {
|
|
277
|
+
"default-src": "'none'",
|
|
278
|
+
"frame-ancestors": "'none'",
|
|
279
|
+
"base-uri": "'none'",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/** Balanced security for blog pages */
|
|
284
|
+
BLOG: {
|
|
285
|
+
enableCSP: true,
|
|
286
|
+
frameOptions: "SAMEORIGIN",
|
|
287
|
+
cspDirectives: {
|
|
288
|
+
"default-src": "'self'",
|
|
289
|
+
"script-src": "'self' 'unsafe-inline'",
|
|
290
|
+
"style-src": "'self' 'unsafe-inline' https:",
|
|
291
|
+
"img-src": "'self' data: https:",
|
|
292
|
+
"font-src": "'self' https:",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
/** Development-friendly settings */
|
|
297
|
+
DEVELOPMENT: {
|
|
298
|
+
enableCSP: false,
|
|
299
|
+
enableHSTS: false,
|
|
300
|
+
frameOptions: "SAMEORIGIN",
|
|
301
|
+
},
|
|
302
|
+
} as const;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Validate and sanitize CSP directive values
|
|
306
|
+
*/
|
|
307
|
+
export function sanitizeCSPDirective(directive: string, value: string): string {
|
|
308
|
+
// Remove potentially dangerous values
|
|
309
|
+
const sanitized = value
|
|
310
|
+
.replace(/javascript:/gi, "") // Remove javascript: URIs
|
|
311
|
+
.replace(/data:/gi, "data:") // Normalize data: URIs
|
|
312
|
+
.replace(/\s+/g, " ") // Normalize whitespace
|
|
313
|
+
.trim();
|
|
314
|
+
|
|
315
|
+
// Validate that value doesn't contain injection attempts
|
|
316
|
+
if (sanitized.includes(";") || sanitized.includes("\n") || sanitized.includes("\r")) {
|
|
317
|
+
console.warn(`Potentially dangerous CSP value for ${directive}: ${value}`);
|
|
318
|
+
return "'none'"; // Safe fallback
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return sanitized;
|
|
322
|
+
}
|