Skip to content

Commit 1caec7b

Browse files
Christian Shearerclaude
andcommitted
Security audit fixes: XSS, API key leak, headers, rate limiting
CRITICAL: - Fix XSS: sessionId now JSON.stringify'd + script-escaped in JS contexts - Fix XSS: org.name uses escapeHtml() in HTML, JSON.stringify in JS HIGH: - Remove API key from already-provisioned confirm-payment response - Add helmet for security headers (X-Frame-Options, HSTS, etc.) - Truncate API keys in console.log (show first 12 chars only) MEDIUM: - Rate limit confirm-payment: 10 req/min per IP - Sanitize error messages in web-facing routes (generic client msg) - Fix refCode JSON.stringify script breakout with \u003c escaping Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cf572f3 commit 1caec7b

File tree

5 files changed

+49
-14
lines changed

5 files changed

+49
-14
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"better-sqlite3": "^12.6.2",
6363
"ethers": "^6.16.0",
6464
"express": "^5.2.1",
65+
"helmet": "^8.1.0",
6566
"osmojs": "^16.15.0",
6667
"stripe": "^20.3.1"
6768
},

src/server/api-routes.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,26 @@ export function createApiRoutes(
199199
res.json(paymentRequiredBody.payment);
200200
});
201201

202+
// Rate limiter for confirm-payment endpoint (10 req/min per IP)
203+
const confirmPaymentLimiter = new Map<string, { count: number; windowStart: number }>();
204+
202205
// POST /api/v1/confirm-payment — agent confirms a crypto payment
203206
router.post("/api/v1/confirm-payment", async (req: Request, res: Response) => {
204207
try {
208+
// IP-based rate limiting
209+
const ip = req.ip || req.socket.remoteAddress || "unknown";
210+
const rlNow = Date.now();
211+
const rlWindow = confirmPaymentLimiter.get(ip);
212+
if (rlWindow && rlNow - rlWindow.windowStart < 60_000 && rlWindow.count >= 10) {
213+
apiError(res, 429, "RATE_LIMITED", "Too many payment confirmation attempts. Try again in a minute.");
214+
return;
215+
}
216+
if (!rlWindow || rlNow - rlWindow.windowStart >= 60_000) {
217+
confirmPaymentLimiter.set(ip, { count: 1, windowStart: rlNow });
218+
} else {
219+
rlWindow.count++;
220+
}
221+
205222
const { chain, tx_hash, email } = req.body ?? {};
206223

207224
if (!chain || typeof chain !== "string") {
@@ -218,7 +235,7 @@ export function createApiRoutes(
218235
if (existing) {
219236
if (existing.status === "provisioned" && existing.user_id) {
220237
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(existing.user_id) as User | undefined;
221-
res.json({ status: "already_provisioned", api_key: user?.api_key, message: "This payment has already been processed." });
238+
res.json({ status: "already_provisioned", message: "This payment has already been processed. Check your email for your API key." });
222239
} else {
223240
res.json({ status: existing.status, message: "This transaction has already been recorded." });
224241
}

src/server/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import express from "express";
19+
import helmet from "helmet";
1920
import Stripe from "stripe";
2021
import { getDb } from "./db.js";
2122
import { createRoutes } from "./routes.js";
@@ -332,6 +333,11 @@ export function startServer(options: { port?: number; dbPath?: string } = {}) {
332333
app.use(express.json());
333334
app.use(express.urlencoded({ extended: false }));
334335

336+
// Security headers (relaxed CSP since we use inline scripts extensively)
337+
app.use(helmet({
338+
contentSecurityPolicy: false,
339+
}));
340+
335341
// Mount routes (landing page, feedback, cancel, checkout-page always work;
336342
// Stripe-dependent routes like /subscribe, /checkout, /webhook, /success, /manage
337343
// are conditionally registered inside createRoutes when stripe is non-null)

src/server/routes.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,7 @@ ${SUPPORTED_LANGS.map(l => ` <link rel="alternate" hreflang="${l}" href="${base
804804
805805
function doSubscribe(tier, interval) {
806806
var body = { tier: tier, interval: interval };
807-
${refCode ? `body.referral_code = ${JSON.stringify(refCode)};` : ""}
807+
${refCode ? `body.referral_code = ${JSON.stringify(refCode).replace(/</g, "\\u003c")};` : ""}
808808
fetch('/subscribe', {
809809
method: 'POST',
810810
headers: { 'Content-Type': 'application/json' },
@@ -920,7 +920,7 @@ ${betaBannerJS()}
920920
} catch (err) {
921921
const msg = err instanceof Error ? err.message : String(err);
922922
console.error("Subscribe error:", msg);
923-
res.status(500).json({ error: msg });
923+
res.status(500).json({ error: "An internal error occurred. Please try again." });
924924
}
925925
});
926926

@@ -996,7 +996,7 @@ ${betaBannerJS()}
996996
} catch (err) {
997997
const msg = err instanceof Error ? err.message : String(err);
998998
console.error("Subscribe-org error:", msg);
999-
res.status(500).json({ error: msg });
999+
res.status(500).json({ error: "An internal error occurred. Please try again." });
10001000
}
10011001
});
10021002

@@ -1038,7 +1038,8 @@ ${betaBannerJS()}
10381038
res.json({ ok: true, publicity_opt_in: !!opt_in });
10391039
} catch (err) {
10401040
const msg = err instanceof Error ? err.message : String(err);
1041-
res.status(500).json({ error: msg });
1041+
console.error("Org publicity error:", msg);
1042+
res.status(500).json({ error: "An internal error occurred. Please try again." });
10421043
}
10431044
});
10441045

@@ -1093,7 +1094,7 @@ ${betaBannerJS()}
10931094
} catch (err) {
10941095
const msg = err instanceof Error ? err.message : String(err);
10951096
console.error("Checkout error:", msg);
1096-
res.status(500).json({ error: msg });
1097+
res.status(500).json({ error: "An internal error occurred. Please try again." });
10971098
}
10981099
});
10991100

@@ -1165,7 +1166,7 @@ ${betaBannerJS()}
11651166
} catch (err) {
11661167
const msg = err instanceof Error ? err.message : String(err);
11671168
console.error("Boost checkout error:", msg);
1168-
res.status(500).json({ error: msg });
1169+
res.status(500).json({ error: "An internal error occurred. Please try again." });
11691170
}
11701171
});
11711172

@@ -1217,7 +1218,7 @@ ${betaBannerJS()}
12171218
let user = email ? getUserByEmail(db, email) : undefined;
12181219
if (!user) {
12191220
user = createUser(db, email, stripeCustomerId);
1220-
console.log(`New user created: ${user.api_key} (${email})`);
1221+
console.log(`New user created: ${user.api_key.slice(0, 12)}... (${email})`);
12211222
}
12221223

12231224
// Extract billing interval from the Stripe subscription (if subscription mode)
@@ -1424,7 +1425,7 @@ export REGEN_BALANCE_URL=${baseUrl}</pre>
14241425
<div class="regen-card__body">
14251426
<h2 style="color:var(--regen-navy);margin:0 0 8px;font-size:18px;font-weight:700;">Share your commitment?</h2>
14261427
<p style="color:var(--regen-gray-700);font-size:14px;margin:0 0 14px;line-height:1.6;">
1427-
Would you like us to feature <strong>${org.name.replace(/</g, "&lt;")}</strong> on our website and social media as an organization committed to regenerative AI? This helps inspire others to follow your lead.
1428+
Would you like us to feature <strong>${escapeHtml(org.name)}</strong> on our website and social media as an organization committed to regenerative AI? This helps inspire others to follow your lead.
14281429
</p>
14291430
<div id="publicity-prompt" style="display:flex;gap:10px;align-items:center;">
14301431
<button onclick="setPublicity(true)" class="regen-btn regen-btn--solid regen-btn--sm">Yes, share it</button>
@@ -1438,12 +1439,12 @@ export REGEN_BALANCE_URL=${baseUrl}</pre>
14381439
fetch('/org/publicity', {
14391440
method: 'POST',
14401441
headers: { 'Content-Type': 'application/json' },
1441-
body: JSON.stringify({ org_id: ${org.id}, opt_in: optIn, session_id: '${sessionId}' })
1442+
body: JSON.stringify({ org_id: ${org.id}, opt_in: optIn, session_id: ${JSON.stringify(sessionId).replace(/</g, "\\u003c")} })
14421443
}).then(function(r) { return r.json(); }).then(function(data) {
14431444
document.getElementById('publicity-prompt').style.display = 'none';
14441445
var saved = document.getElementById('publicity-saved');
14451446
saved.style.display = 'block';
1446-
saved.textContent = optIn ? 'Thank you! We\\'ll feature ${org.name.replace(/'/g, "\\'")} on our site.' : 'No problem — you can change this anytime from your dashboard.';
1447+
saved.textContent = optIn ? 'Thank you! We\\'ll feature ' + ${JSON.stringify(org.name).replace(/</g, "\\u003c")} + ' on our site.' : 'No problem — you can change this anytime from your dashboard.';
14471448
});
14481449
}
14491450
</script>
@@ -1495,7 +1496,7 @@ export REGEN_BALANCE_URL=${baseUrl}</pre>
14951496
fetch('/profile/display-name', {
14961497
method: 'POST',
14971498
headers: { 'Content-Type': 'application/json' },
1498-
body: JSON.stringify({ session_id: '${sessionId}', display_name: name })
1499+
body: JSON.stringify({ session_id: ${JSON.stringify(sessionId).replace(/</g, "\\u003c")}, display_name: name })
14991500
}).then(function(r) { return r.json(); }).then(function(data) {
15001501
if (data.ok) {
15011502
document.getElementById('profilePrompt').style.display = 'none';
@@ -1523,7 +1524,7 @@ export REGEN_BALANCE_URL=${baseUrl}</pre>
15231524
fetch('/profile/display-name', {
15241525
method: 'POST',
15251526
headers: { 'Content-Type': 'application/json' },
1526-
body: JSON.stringify({ session_id: '${sessionId}', display_name: 'My On-Chain Proof' })
1527+
body: JSON.stringify({ session_id: ${JSON.stringify(sessionId).replace(/</g, "\\u003c")}, display_name: 'My On-Chain Proof' })
15271528
}).catch(function() {});
15281529
}
15291530
</script>
@@ -1956,7 +1957,7 @@ async function handleSubscriptionCreated(db: Database.Database, sub: Stripe.Subs
19561957
let user = email ? getUserByEmail(db, email) : undefined;
19571958
if (!user) {
19581959
user = createUser(db, email, customerId ?? null);
1959-
console.log(`New user created for subscription: ${user.api_key} (${email})`);
1960+
console.log(`New user created for subscription: ${user.api_key.slice(0, 12)}... (${email})`);
19601961
}
19611962

19621963
const priceItem = sub.items?.data?.[0]?.price;

0 commit comments

Comments
 (0)