Skip to content

Commit 098f548

Browse files
Toreburnclaude
andcommitted
Security hardening from audit findings
- Add rate limiting (5 req/hr per IP) to vendor-request API - Add input validation: type checks, length limits, URL validation - Sanitize Markdown injection in GitHub Issue body - Block wildcard CORS origin - Validate severity values against allowlist (XSS prevention) - Escape CVSS values in DOM rendering - Deploy only static files to GitHub Pages (hide scripts, api, config) - Add Dependabot for npm and GitHub Actions dependencies - Sanitize error messages in public-facing fetch logs - Add SRI hash to Umami analytics script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b607da6 commit 098f548

8 files changed

Lines changed: 126 additions & 19 deletions

File tree

.github/dependabot.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "npm"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"
7+
- package-ecosystem: "github-actions"
8+
directory: "/"
9+
schedule:
10+
interval: "weekly"

.github/workflows/deploy.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@ on:
55
branches: [ "master" ]
66
workflow_dispatch:
77

8-
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
98
permissions:
109
contents: read
1110
pages: write
1211
id-token: write
1312

14-
# Allow only one concurrent deployment
1513
concurrency:
1614
group: "pages"
1715
cancel-in-progress: false
@@ -25,15 +23,23 @@ jobs:
2523
steps:
2624
- name: Checkout
2725
uses: actions/checkout@v4
28-
26+
27+
- name: Prepare site directory
28+
run: |
29+
mkdir -p _site/data
30+
cp index.html script.js styles.css favicon.ico _site/
31+
cp CNAME robots.txt sitemap.xml _site/ 2>/dev/null || true
32+
cp -r data/ _site/data/ 2>/dev/null || true
33+
cp logs.html logs.js _site/ 2>/dev/null || true
34+
2935
- name: Setup Pages
3036
uses: actions/configure-pages@v4
31-
37+
3238
- name: Upload artifact
3339
uses: actions/upload-pages-artifact@v3
3440
with:
35-
path: '.'
36-
41+
path: '_site'
42+
3743
- name: Deploy to GitHub Pages
3844
id: deployment
3945
uses: actions/deploy-pages@v4

.github/workflows/fetch-all-patches.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,21 @@ jobs:
159159
git add data/
160160
git diff --staged --quiet || (git commit -m "Update patch data $(date -u +%Y-%m-%d)" && git push)
161161
162+
- name: Prepare site directory
163+
run: |
164+
mkdir -p _site/data
165+
cp index.html script.js styles.css favicon.ico _site/
166+
cp CNAME robots.txt sitemap.xml _site/ 2>/dev/null || true
167+
cp -r data/ _site/data/ 2>/dev/null || true
168+
cp logs.html logs.js _site/ 2>/dev/null || true
169+
162170
- name: Setup Pages
163171
uses: actions/configure-pages@v4
164172

165173
- name: Upload Pages artifact
166174
uses: actions/upload-pages-artifact@v3
167175
with:
168-
path: '.'
176+
path: '_site'
169177

170178
- name: Deploy to GitHub Pages
171179
id: deployment

.github/workflows/update-patches.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,21 @@ jobs:
4343
git add data/patches.json data/vendors/*.json
4444
git diff --staged --quiet || (git commit -m "Update patch data [skip ci]" && git push)
4545
46+
- name: Prepare site directory
47+
run: |
48+
mkdir -p _site/data
49+
cp index.html script.js styles.css favicon.ico _site/
50+
cp CNAME robots.txt sitemap.xml _site/ 2>/dev/null || true
51+
cp -r data/ _site/data/ 2>/dev/null || true
52+
cp logs.html logs.js _site/ 2>/dev/null || true
53+
4654
- name: Setup Pages
4755
uses: actions/configure-pages@v4
4856

4957
- name: Upload artifact
5058
uses: actions/upload-pages-artifact@v3
5159
with:
52-
path: '.'
60+
path: '_site'
5361

5462
- name: Deploy to GitHub Pages
5563
id: deployment

api/vendor-request.js

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
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+
130
export 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
}),

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
}
3838
</script>
3939
<!-- Umami Analytics (privacy-friendly, cookie-free) -->
40-
<script defer src="https://cloud.umami.is/script.js" data-website-id="45f6e89a-17db-42d4-9d7f-4231d66dc2ca"></script>
40+
<script defer src="https://cloud.umami.is/script.js" data-website-id="45f6e89a-17db-42d4-9d7f-4231d66dc2ca" integrity="sha384-njZ5sCqL2nQlDz+tuOzt4BOOdB4zc8UVx7AiE6EGDrjYZPfNHel8zAmY2ESoQilv" crossorigin="anonymous"></script>
4141
</head>
4242
<body>
4343
<div class="app">

script.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,8 @@ document.addEventListener('DOMContentLoaded', () => {
282282
}
283283

284284
// ── Rendering ──
285+
const VALID_SEVERITIES = ['critical', 'high', 'medium', 'low', 'unknown', 'important'];
286+
285287
function displayPatches(list) {
286288
if (!list.length) {
287289
patchFeed.innerHTML = '<div class="no-results">No patches match your filters</div>';
@@ -291,7 +293,8 @@ document.addEventListener('DOMContentLoaded', () => {
291293
list.sort((a, b) => new Date(b.date) - new Date(a.date));
292294

293295
patchFeed.innerHTML = list.map(p => {
294-
const sev = (p.severity || 'unknown').toLowerCase();
296+
const rawSev = (p.severity || '').toLowerCase();
297+
const sev = VALID_SEVERITIES.includes(rawSev) ? rawSev : 'unknown';
295298
let displaySev = sev.charAt(0).toUpperCase() + sev.slice(1);
296299
if (displaySev === 'Unknown') displaySev = 'Bug Fix';
297300

@@ -300,6 +303,7 @@ document.addEventListener('DOMContentLoaded', () => {
300303
const comp = escapeHtml(p.component || '');
301304
const vendor = escapeHtml((p.vendor || '').toUpperCase());
302305
const cvss = p.cvss || p.cvssScore || null;
306+
const safeCvss = cvss ? escapeHtml(String(cvss)) : null;
303307
const cve = escapeHtml(p.cve || '');
304308

305309
return `<div class="patch-card severity-${sev}">
@@ -311,7 +315,7 @@ document.addEventListener('DOMContentLoaded', () => {
311315
<div class="patch-meta">
312316
<span class="tag tag-vendor">${vendor}</span>
313317
<span class="tag tag-severity-${sev}">${displaySev}</span>
314-
${cvss ? `<span class="tag tag-cvss" title="CVSS Base Score">CVSS ${cvss}</span>` : ''}
318+
${safeCvss ? `<span class="tag tag-cvss" title="CVSS Base Score">CVSS ${safeCvss}</span>` : ''}
315319
${cve ? `<span class="tag tag-cve">${cve}</span>` : ''}
316320
${comp ? `<span class="tag">${comp}</span>` : ''}
317321
</div>

scripts/fetch-patches.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ async function fetchPatches() {
7171
logs.push(`[INFO] No new patches for ${vendorData.vendor} in the last week`);
7272
}
7373
} catch (error) {
74-
logs.push(`[ERROR] Failed to process ${file}: ${error.message}`);
74+
console.error(`Full error for ${file}:`, error);
75+
logs.push(`[ERROR] ${file} - fetch failed`);
7576
}
7677
}
7778
}
@@ -113,7 +114,8 @@ async function fetchPatches() {
113114

114115
logs.push(`[SUCCESS] Updated patches.json with ${allPatches.length} total patches (${newPatchCount} new)`);
115116
} catch (error) {
116-
logs.push(`[ERROR] Failed to fetch patches: ${error.message}`);
117+
console.error('Failed to fetch patches:', error);
118+
logs.push(`[ERROR] Patch aggregation failed`);
117119
}
118120

119121
return logs;

0 commit comments

Comments
 (0)