Skip to content

Commit 441defe

Browse files
committed
more security hardening
1 parent 4cc6ea8 commit 441defe

7 files changed

Lines changed: 102 additions & 11 deletions

File tree

.github/workflows/website-test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ on:
1111
paths:
1212
- "apps/web/**"
1313

14+
permissions:
15+
contents: read
16+
1417
jobs:
1518
web-tests:
1619
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6666
#### Marketing Site
6767
- **Contact CTA destination** — changed the marketing contact link from email to Discord
6868

69+
### Security
70+
71+
- **SES callback SSRF hardening**`apps/web/src/app/api/ses_callback/route.ts` no longer fetches user-provided `SubscribeURL` directly; it now constructs a trusted AWS SNS confirmation URL from validated `TopicArn`/`Token` components before issuing the request
72+
- **SES callback log-safety hardening** — replaced ad-hoc request/parse logging in `apps/web/src/app/api/ses_callback/route.ts` with constant-format structured logs to avoid tainted-format-string risks from untrusted payload fields
73+
- **SPF verification sanitization fix**`apps/web/src/server/service/domain-service.ts` now parses SPF TXT mechanisms and validates `include:` domains (`amazonses.com` or subdomains) instead of broad substring checks
74+
- **DKIM key strength upgrade**`apps/web/src/server/aws/ses.ts` now generates 2048-bit RSA keys (up from 1024-bit)
75+
- **Stripe seed secret logging removal**`packages/scripts/stripe-seed.ts` no longer logs any portion of `STRIPE_SECRET_KEY`
76+
- **Python webhook example exception exposure fix**`packages/python-sdk/example/webhook-test-project/receiver.py` now returns a generic verification failure message and avoids exposing exception text to clients
77+
- **Workflow least-privilege permissions**`.github/workflows/website-test.yml` now sets explicit `permissions` with `contents: read`
78+
6979
---
7080

7181
## [0.2.4] - 2026-05-08

apps/web/src/app/api/ses_callback/route.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,19 @@ export async function GET() {
1414
export async function POST(req: Request) {
1515
const data = await req.json();
1616

17-
console.log(data, data.Message);
17+
logger.debug(
18+
{
19+
type: data?.Type,
20+
topicArn: data?.TopicArn,
21+
messageId: data?.MessageId,
22+
hasMessage: typeof data?.Message === "string",
23+
},
24+
"Received SES callback payload",
25+
);
1826

1927
const isEventValid = await checkEventValidity(data);
2028

21-
console.log("Is event valid: ", isEventValid);
29+
logger.debug({ isEventValid }, "SES callback topic validation result");
2230

2331
if (!isEventValid) {
2432
return Response.json({ data: "Event is not valid" });
@@ -42,7 +50,10 @@ export async function POST(req: Request) {
4250

4351
return Response.json({ data: "Success" });
4452
} catch (e) {
45-
console.error(e);
53+
logger.error(
54+
{ error: e instanceof Error ? e.message : "Unknown error" },
55+
"Failed to parse SES callback message",
56+
);
4657
return Response.json({ data: "Error is parsing hook" });
4758
}
4859
}
@@ -51,7 +62,9 @@ export async function POST(req: Request) {
5162
* Handles the subscription confirmation event. called only once for a webhook
5263
*/
5364
async function handleSubscription(message: any) {
54-
await fetch(message.SubscribeURL, {
65+
const subscribeUrl = buildSnsSubscribeConfirmUrl(message);
66+
67+
await fetch(subscribeUrl, {
5568
method: "GET",
5669
});
5770

@@ -80,6 +93,42 @@ async function handleSubscription(message: any) {
8093
return Response.json({ data: "Success" });
8194
}
8295

96+
/**
97+
* Build a trusted SNS subscription confirmation URL from message fields.
98+
* We intentionally do not use message.SubscribeURL directly to prevent SSRF.
99+
*/
100+
function buildSnsSubscribeConfirmUrl(message: {
101+
TopicArn?: string;
102+
Token?: string;
103+
}) {
104+
const topicArn = message.TopicArn;
105+
const token = message.Token;
106+
107+
if (!topicArn || !token) {
108+
throw new Error("Invalid SNS subscription payload");
109+
}
110+
111+
const arnParts = topicArn.split(":");
112+
if (arnParts.length < 6 || arnParts[2] !== "sns") {
113+
throw new Error("Invalid SNS TopicArn");
114+
}
115+
116+
const partition = arnParts[1];
117+
const region = arnParts[3];
118+
119+
if (!region) {
120+
throw new Error("SNS TopicArn is missing region");
121+
}
122+
123+
const domain = partition === "aws-cn" ? "amazonaws.com.cn" : "amazonaws.com";
124+
const confirmUrl = new URL(`https://sns.${region}.${domain}/`);
125+
confirmUrl.searchParams.set("Action", "ConfirmSubscription");
126+
confirmUrl.searchParams.set("TopicArn", topicArn);
127+
confirmUrl.searchParams.set("Token", token);
128+
129+
return confirmUrl.toString();
130+
}
131+
83132
/**
84133
* A simple check to ensure that the event is from the correct topic
85134
*/

apps/web/src/server/aws/ses.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function getSesClient(region: string) {
6060

6161
function generateKeyPair() {
6262
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
63-
modulusLength: 1024, // Length of your key in bits
63+
modulusLength: 2048, // Minimum recommended RSA key length
6464
publicKeyEncoding: {
6565
type: "spki", // Recommended to be 'spki' by the Node.js docs
6666
format: "pem",

apps/web/src/server/service/domain-service.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,29 @@ async function checkDkimDnsRecord(
143143
async function checkSpfDnsRecord(mailDomain: string): Promise<DnsCheckResult> {
144144
try {
145145
const records = await dnsResolveTxt(mailDomain);
146-
const flat = records.flat().join(" ");
147-
return flat.includes("v=spf1") && flat.includes("amazonses.com")
148-
? "found"
149-
: "not_found";
146+
const spfRecords = records
147+
.map((parts) => parts.join(""))
148+
.map((record) => record.trim().toLowerCase())
149+
.filter((record) => record.startsWith("v=spf1"));
150+
151+
const hasAmazonSesInclude = spfRecords.some((record) => {
152+
const mechanisms = record.split(/\s+/).filter(Boolean);
153+
154+
return mechanisms.some((mechanism) => {
155+
const normalized = mechanism.replace(/^[+\-~?]/, "");
156+
if (!normalized.startsWith("include:")) {
157+
return false;
158+
}
159+
160+
const includeDomain = normalized.slice("include:".length);
161+
return (
162+
includeDomain === "amazonses.com" ||
163+
includeDomain.endsWith(".amazonses.com")
164+
);
165+
});
166+
});
167+
168+
return hasAmazonSesInclude ? "found" : "not_found";
150169
} catch {
151170
return "not_found";
152171
}

packages/python-sdk/example/webhook-test-project/receiver.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,17 @@ def webhook() -> ResponseReturnValue:
2222
try:
2323
event = webhooks.construct_event(raw_body, headers=request.headers)
2424
except WebhookVerificationError as exc:
25-
return jsonify({"ok": False, "code": exc.code, "message": str(exc)}), 400
25+
app.logger.warning("Webhook verification failed: %s", exc.code)
26+
return (
27+
jsonify(
28+
{
29+
"ok": False,
30+
"code": exc.code,
31+
"message": "Webhook signature verification failed",
32+
}
33+
),
34+
400,
35+
)
2636

2737
print(f"Received event: {event['type']}")
2838

packages/scripts/stripe-seed.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ async function main() {
2727
});
2828

2929
console.log(`📦 Environment : ${ENVIRONMENT}`);
30-
console.log(`🔑 Stripe key : ${STRIPE_SECRET_KEY.slice(0, 20)}...`);
30+
console.log("🔑 Stripe key : configured");
3131
console.log("\n📝 Syncing plans to Stripe...\n");
3232

3333
const result = await syncPlansToStripe(stripe, ENVIRONMENT);

0 commit comments

Comments
 (0)