Skip to content

Commit 2980187

Browse files
committed
feat(security): add Content-Security-Policy and security headers via Helmet
Add comprehensive security headers via the Helmet middleware, including a strict Content-Security-Policy with per-request nonces for inline scripts. Security headers set on every response: - Content-Security-Policy (nonce-based, blocks injected scripts) - X-Frame-Options: SAMEORIGIN (clickjacking prevention) - X-Content-Type-Options: nosniff - Strict-Transport-Security - Referrer-Policy: no-referrer Inline event handlers (onclick) converted to addEventListener for CSP compliance. Closes #59
1 parent eeefc56 commit 2980187

5 files changed

Lines changed: 125 additions & 13 deletions

File tree

frontend/leaderboard.html

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ <h1 class="page-title">Leaderboard</h1>
128128
<button
129129
id="load-more-btn"
130130
class="btn btn-secondary"
131-
onclick="loadMoreNodes()"
132131
style="
133132
font-family: &quot;Space Mono&quot;, monospace;
134133
font-size: 0.9rem;
@@ -141,7 +140,6 @@ <h1 class="page-title">Leaderboard</h1>
141140
<button
142141
id="scroll-top-btn"
143142
class="btn btn-primary"
144-
onclick="window.scrollTo({ top: 0, behavior: 'smooth' })"
145143
style="
146144
font-family: &quot;Space Mono&quot;, monospace;
147145
font-size: 0.9rem;
@@ -155,7 +153,7 @@ <h1 class="page-title">Leaderboard</h1>
155153
</div>
156154
</main>
157155

158-
<script>
156+
<script nonce="__NONCE__">
159157
document.addEventListener("DOMContentLoaded", () => {
160158
document.querySelectorAll(".tab").forEach((tab) => {
161159
tab.addEventListener("click", () => {
@@ -195,6 +193,18 @@ <h1 class="page-title">Leaderboard</h1>
195193
}
196194
});
197195

196+
// Bind pagination buttons (replaces inline onclick handlers)
197+
document
198+
.getElementById("load-more-btn")
199+
.addEventListener("click", () => {
200+
loadMoreNodes();
201+
});
202+
document
203+
.getElementById("scroll-top-btn")
204+
.addEventListener("click", () => {
205+
window.scrollTo({ top: 0, behavior: "smooth" });
206+
});
207+
198208
fetchLeaderboardData();
199209
});
200210

frontend/registration.html

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ <h1 class="page-title">Join the Leaderboard</h1>
126126
<a href="/leaderboard" class="btn btn-secondary"
127127
>view_leaderboard</a
128128
>
129-
<button class="btn btn-primary" onclick="fullResetForm()">
129+
<button class="btn btn-primary" id="register-another-btn">
130130
register_another
131131
</button>
132132
</div>
@@ -175,15 +175,15 @@ <h1 class="page-title">Join the Leaderboard</h1>
175175
"
176176
></div>
177177
<div style="display: flex; justify-content: center">
178-
<button class="btn btn-error" onclick="dismissError()">
178+
<button class="btn btn-error" id="dismiss-error-btn">
179179
retry_input
180180
</button>
181181
</div>
182182
</div>
183183
</div>
184184
</main>
185185

186-
<script>
186+
<script nonce="__NONCE__">
187187
function showSuccess(name) {
188188
document.getElementById("registration-form").classList.add("hidden");
189189
document.getElementById("success-message").classList.remove("hidden");
@@ -526,6 +526,14 @@ <h1 class="page-title">Join the Leaderboard</h1>
526526
registerBtn.style.display = "inline-block";
527527
}
528528
});
529+
530+
// Bind buttons (replaces inline onclick handlers)
531+
document
532+
.getElementById("register-another-btn")
533+
.addEventListener("click", fullResetForm);
534+
document
535+
.getElementById("dismiss-error-btn")
536+
.addEventListener("click", dismissError);
529537
});
530538
</script>
531539
<script src="js/footer.js"></script>

package-lock.json

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"dependencies": {
2222
"axios": "^1.10.0",
2323
"cors": "^2.8.5",
24-
"express": "^5.1.0"
24+
"express": "^5.1.0",
25+
"helmet": "^8.2.0"
2526
},
2627
"devDependencies": {
2728
"prettier": "^3.8.3"

server.js

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,113 @@
11
const express = require("express");
2+
const helmet = require("helmet");
23
const cors = require("cors");
34
const path = require("path");
45
const fs = require("fs");
6+
const crypto = require("crypto");
7+
58
const app = express();
69
const PORT = process.env.PORT || 3000;
710

11+
// CORS — allow cross-origin requests (used by uptime monitor, etc.)
812
app.use(cors());
9-
app.use(express.static(path.join(__dirname, "frontend")));
13+
14+
// ---------------------------------------------------------------------------
15+
// 1. Per-request nonce generator (used by CSP and HTML nonce injection)
16+
// ---------------------------------------------------------------------------
17+
app.use((req, res, next) => {
18+
res.locals.nonce = crypto.randomBytes(16).toString("base64url");
19+
next();
20+
});
21+
22+
// ---------------------------------------------------------------------------
23+
// 2. Security headers via Helmet
24+
// Sets: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options,
25+
// Referrer-Policy, Strict-Transport-Security, and more.
26+
// ---------------------------------------------------------------------------
27+
app.use(
28+
helmet({
29+
contentSecurityPolicy: {
30+
directives: {
31+
defaultSrc: ["'self'"],
32+
// Inline scripts need a per-request nonce; external scripts from 'self'
33+
// are allowed automatically.
34+
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
35+
// Allow inline styles (style attributes) + Google Fonts stylesheet
36+
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
37+
// Google Fonts
38+
fontSrc: ["'self'", "https://fonts.gstatic.com"],
39+
// Images: self + data: URIs (used by matrix canvas)
40+
imgSrc: ["'self'", "data:"],
41+
// No plugins
42+
objectSrc: ["'none'"],
43+
// No framing (clickjacking protection)
44+
frameAncestors: ["'none'"],
45+
// Upgrade HTTP to HTTPS when possible
46+
upgradeInsecureRequests: [],
47+
},
48+
},
49+
crossOriginEmbedderPolicy: false, // keep false so Google Fonts load
50+
}),
51+
);
52+
53+
// ---------------------------------------------------------------------------
54+
// 3. Static assets (JS, CSS, images) — served normally
55+
// HTML files are excluded — they go through nonce injection via routes.
56+
// ---------------------------------------------------------------------------
57+
const staticMiddleware = express.static(path.join(__dirname, "frontend"), {
58+
index: false,
59+
});
60+
61+
app.use((req, res, next) => {
62+
if (req.path.endsWith(".html")) return next();
63+
staticMiddleware(req, res, next);
64+
});
65+
66+
// ---------------------------------------------------------------------------
67+
// 4. HTML page routes — inject per-request nonce into __NONCE__ placeholders
68+
// ---------------------------------------------------------------------------
69+
function serveHtml(res, filePath) {
70+
fs.readFile(filePath, "utf8", (err, data) => {
71+
if (err) {
72+
return res.status(500).send("Error loading page");
73+
}
74+
const html = data.replace(/__NONCE__/g, res.locals.nonce);
75+
res.type("html").send(html);
76+
});
77+
}
1078

1179
app.get("/", (req, res) => {
12-
res.sendFile(path.join(__dirname, "frontend", "index.html"));
80+
serveHtml(res, path.join(__dirname, "frontend", "index.html"));
1381
});
1482

1583
app.get("/leaderboard", (req, res) => {
16-
res.sendFile(path.join(__dirname, "frontend", "leaderboard.html"));
84+
serveHtml(res, path.join(__dirname, "frontend", "leaderboard.html"));
1785
});
1886

1987
app.get("/about", (req, res) => {
20-
res.sendFile(path.join(__dirname, "frontend", "about.html"));
88+
serveHtml(res, path.join(__dirname, "frontend", "about.html"));
2189
});
2290

2391
app.get("/registration", (req, res) => {
24-
res.sendFile(path.join(__dirname, "frontend", "registration.html"));
92+
serveHtml(res, path.join(__dirname, "frontend", "registration.html"));
93+
});
94+
95+
// Redirect direct .html file access so nonce injection still applies
96+
app.get(/\.html$/, (req, res) => {
97+
const cleanPath = req.path.replace(/\.html$/, "");
98+
res.redirect(301, cleanPath || "/");
2599
});
26100

101+
// ---------------------------------------------------------------------------
102+
// 5. Utility endpoints
103+
// ---------------------------------------------------------------------------
27104
app.get("/uptime", (req, res) => {
28105
res.json({ status: "Website is running ✅" });
29106
});
30107

108+
// ---------------------------------------------------------------------------
109+
// 6. 404
110+
// ---------------------------------------------------------------------------
31111
app.use((req, res) => {
32112
res.status(404).send("Page not found");
33113
});

0 commit comments

Comments
 (0)