1+ // Simple in-memory rate limiter (resets on cold start)
2+ const rateLimit = new Map ( ) ;
3+ const RATE_WINDOW_MS = 3600000 ; // 1 hour
4+ const RATE_MAX = 5 ; // max requests per IP per window
5+
6+ function checkRateLimit ( ip ) {
7+ const now = Date . now ( ) ;
8+ const entry = rateLimit . get ( ip ) ;
9+ if ( ! entry || now > entry . resetAt ) {
10+ rateLimit . set ( ip , { count : 1 , resetAt : now + RATE_WINDOW_MS } ) ;
11+ return true ;
12+ }
13+ entry . count ++ ;
14+ return entry . count <= RATE_MAX ;
15+ }
16+
17+ function sanitizeMarkdown ( str ) {
18+ return str . replace ( / [ [ \] ( ) @ # * ` ~ > ! | \\ ] / g, '' ) ;
19+ }
20+
21+ function validateUrl ( str ) {
22+ try {
23+ const parsed = new URL ( str ) ;
24+ return [ 'http:' , 'https:' ] . includes ( parsed . protocol ) ;
25+ } catch {
26+ return false ;
27+ }
28+ }
29+
130export default async function handler ( req , res ) {
231 const allowedOrigin = process . env . ALLOWED_ORIGIN || '' ;
332
33+ if ( allowedOrigin === '*' ) {
34+ console . error ( 'ALLOWED_ORIGIN must not be wildcard' ) ;
35+ return res . status ( 500 ) . json ( { error : 'Server misconfiguration' } ) ;
36+ }
37+
438 // CORS headers
539 res . setHeader ( 'Access-Control-Allow-Origin' , allowedOrigin ) ;
640 res . setHeader ( 'Access-Control-Allow-Methods' , 'POST, OPTIONS' ) ;
@@ -14,31 +48,66 @@ export default async function handler(req, res) {
1448 return res . status ( 405 ) . json ( { error : 'Method not allowed' } ) ;
1549 }
1650
51+ // Rate limit by IP
52+ const forwarded = req . headers [ 'x-forwarded-for' ] ;
53+ const ip = forwarded ? forwarded . split ( ',' ) [ 0 ] . trim ( ) : 'unknown' ;
54+ if ( ! checkRateLimit ( ip ) ) {
55+ return res . status ( 429 ) . json ( { error : 'Too many requests. Try again later.' } ) ;
56+ }
57+
1758 const { vendorName, feedUrl, notes } = req . body || { } ;
1859
60+ // Type validation
1961 if ( ! vendorName || typeof vendorName !== 'string' || ! vendorName . trim ( ) ) {
2062 return res . status ( 400 ) . json ( { error : 'Vendor name is required' } ) ;
2163 }
64+ if ( feedUrl !== undefined && feedUrl !== null && typeof feedUrl !== 'string' ) {
65+ return res . status ( 400 ) . json ( { error : 'Invalid feed URL' } ) ;
66+ }
67+ if ( notes !== undefined && notes !== null && typeof notes !== 'string' ) {
68+ return res . status ( 400 ) . json ( { error : 'Invalid notes' } ) ;
69+ }
70+
71+ // Length validation
72+ if ( vendorName . trim ( ) . length > 200 ) {
73+ return res . status ( 400 ) . json ( { error : 'Vendor name too long' } ) ;
74+ }
75+ if ( feedUrl && feedUrl . trim ( ) . length > 2000 ) {
76+ return res . status ( 400 ) . json ( { error : 'Feed URL too long' } ) ;
77+ }
78+ if ( notes && notes . trim ( ) . length > 5000 ) {
79+ return res . status ( 400 ) . json ( { error : 'Notes too long' } ) ;
80+ }
81+
82+ // URL validation
83+ if ( feedUrl && feedUrl . trim ( ) && ! validateUrl ( feedUrl . trim ( ) ) ) {
84+ return res . status ( 400 ) . json ( { error : 'Invalid URL format or protocol' } ) ;
85+ }
2286
2387 const ghToken = process . env . GITHUB_TOKEN ;
2488 if ( ! ghToken ) {
2589 console . error ( 'GITHUB_TOKEN not configured' ) ;
2690 return res . status ( 500 ) . json ( { error : 'Server configuration error' } ) ;
2791 }
2892
93+ // Sanitize inputs for Markdown injection
94+ const safeName = sanitizeMarkdown ( vendorName . trim ( ) ) ;
95+ const safeUrl = feedUrl ? sanitizeMarkdown ( feedUrl . trim ( ) ) : '' ;
96+ const safeNotes = notes ? sanitizeMarkdown ( notes . trim ( ) ) : '' ;
97+
2998 // Build issue body
3099 const bodyLines = [
31100 '## Vendor Request' ,
32101 '' ,
33- `**Vendor Name:** ${ vendorName . trim ( ) } ` ,
102+ `**Vendor Name:** ${ safeName } ` ,
34103 ] ;
35104
36- if ( feedUrl && feedUrl . trim ( ) ) {
37- bodyLines . push ( `**Security Feed URL:** ${ feedUrl . trim ( ) } ` ) ;
105+ if ( safeUrl ) {
106+ bodyLines . push ( `**Security Feed URL:** ${ safeUrl } ` ) ;
38107 }
39108
40- if ( notes && notes . trim ( ) ) {
41- bodyLines . push ( '' , `**Notes:** ${ notes . trim ( ) } ` ) ;
109+ if ( safeNotes ) {
110+ bodyLines . push ( '' , `**Notes:** ${ safeNotes } ` ) ;
42111 }
43112
44113 try {
@@ -51,7 +120,7 @@ export default async function handler(req, res) {
51120 'X-GitHub-Api-Version' : '2022-11-28' ,
52121 } ,
53122 body : JSON . stringify ( {
54- title : `Vendor Request: ${ vendorName . trim ( ) } ` ,
123+ title : `Vendor Request: ${ safeName . substring ( 0 , 100 ) } ` ,
55124 body : bodyLines . join ( '\n' ) ,
56125 labels : [ 'vendor-request' ] ,
57126 } ) ,
0 commit comments