feat(blog): notify google chat on newsletter form submit - #414
Conversation
forward each newsletter/lead submission to a google chat space via an incoming webhook, mirroring the landing repo's trial form. adds a server-side /api/blog-lead-notify handler that holds the webhook secret (GOOGLE_CHAT_WEBHOOK_URL, not exposed to the browser) and posts a formatted message. wired into subscribe-newsletter as fire-and-forget, fail-open so it never blocks the subscription or lead capture. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
dhananjay6561
left a comment
There was a problem hiding this comment.
reviewed with the code-review-skill (typescript + security guides). nice clean change overall, the fail-open design and keeping the webhook as a server only secret (not NEXT_PUBLIC) is exactly right, and re-validating on the server instead of trusting the client is good.
the one thing i'd want sorted before merge is that /api/blog-lead-notify is a fully open unauthenticated POST that pushes messages straight into the team chat space, with no recaptcha or rate limit. the sibling blog-mql path verifies a recaptcha enterprise token and this one doesn't, even though the client already mints a token. left inline notes on that plus a couple of smaller things (the honeypot never actually fires, and the chat text isn't escaped). details inline.
severity key: 🔴 blocking, 🟡 important, 🟢 nit.
- escape user fields before rendering into the chat message and render page as plain text instead of a <url|label> link, so a direct POST can't inject formatting, fake links or break the layout - make the honeypot real: add a hidden company_website input to the form so bots that autofill it are actually dropped - add a best-effort per-ip rate limit to blunt casual flooding - warn once (not error every submit) when the webhook env is unset reCAPTCHA verification of the endpoint is left as a follow-up; it needs project-id + api-key secrets this repo doesn't hold. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
- derive the client IP from x-real-ip (the trusted value on vercel), and fall back to the LAST x-forwarded-for hop, not the leftmost. the leftmost token is client supplied so an attacker could rotate it and dodge the limit entirely, which made the previous limit cosmetic. - evict stale IPs once the map passes a key cap so it can't grow unbounded for the life of the instance. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
drop the rate limit, field sanitizing and x-real-ip handling added earlier. landing's trial-form endpoint doesn't do any of that, and the ask was a straight replication. keeps honeypot + email validation + fail-open post, same shape as landing. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
dhananjay6561
left a comment
There was a problem hiding this comment.
second pass, this time reading it against the patterns already in this repo (blog-mql.ts, proxy-image.ts, revalidate.ts) rather than in isolation.
the good stuff holds up: webhook stays a server only secret (not NEXT_PUBLIC), fail-open so a chat hiccup never breaks a subscribe, server side re-validation instead of trusting the client, and routing through router.basePath is correct since the app is served under /blog. all good.
the theme of my comments is that this endpoint doesn't follow the conventions the neighbouring code already established: blog-mql caps field lengths + uses an abort timeout + gates logging on NODE_ENV, proxy-image validates outbound urls against an allowlist, revalidate gates its POST. this one does none of those, and it's a public write that fans out to the team chat space. inline below, ordered by severity.
severity key: 🔴 blocking, 🟡 important, 🟢 nit.
adopt the patterns the neighbouring code already uses, from a second review pass: - abort the google chat fetch at 15s so a hung webhook can't hold a serverless slot open (mirrors blog-mql.ts) - cap field lengths (name 120, email 254, company 160, page 500) so an oversized field can't push the chat text past its ~4096 char limit and silently fail delivery (mirrors blog-mql.ts) - gate logs on NODE_ENV != production and drop to warn, so the shipped-off default doesn't spam prod logs (mirrors blog-mql.ts) deliberately not adding origin/rate-limit/recaptcha gating here: landing's equivalent endpoint has none, and it's tracked as a follow-up. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
dhananjay6561
left a comment
There was a problem hiding this comment.
third pass, focused on test coverage and cross-path consistency (the earlier passes covered security + the handler internals).
the big one: there's already an e2e spec for this exact form, tests/e2e/LeadCapture.spec.ts, and this PR adds a new user-facing network call without touching it. it even has a fail-open case that's the perfect template for the fail-open behaviour you're adding. details inline. also confirmed from that spec's own comment that the sidebar (so this form) renders twice per page, desktop + mobile fallback, which is fine for submit but good to keep in mind.
mirrors landing's TrialForm which early-returns on the honeypot before the network call. the endpoint still re-checks server-side. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
- stub **/blog-lead-notify in LeadCapture.spec.ts so the e2e run stops
hitting the real endpoint, assert it fires with { fullName, email,
companyName, source, page }, and add a case proving a notify failure
doesn't block the subscription (the fail-open behaviour this is built on)
- lowercase the email server-side and carry a source ('blog-newsletter')
into the chat message, so the notify + blog-mql paths tell the same story
Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
|
addressed iteration 4 in 4a59efd. took the two that actually improve things, skipped the ones that dont change the outcome: t3 (double-submit): done, this one matters since the whole point is one chat ping per click. moved the guard onto a useRef and check/set it synchronously. reading the submitting state or the disabled button sees a stale false on a fast double-click because both handlers fire before react re-renders, so two POSTs could slip through. the state still drives the disabled/opacity ui, the ref decides who runs. t4 (~strikethrough): kept it, didnt strip, but extended the comment. ~ is valid in an email local part (rfc atext, and EMAIL_RE accepts it), so stripping it corrupts real lead data the exact same way stripping _ would. its cosmetic only and cant forge a field or link once < > | * and newlines are gone, so its the same call as _. t5 (bare url autolink): left as is. the deceptive-label vector (<url|label>) is already dead since < > | are stripped, so a bare url just autolinks to where it visibly points. not a phishing surface. t6 (honeypot before rate limit): left as is. reordering doesnt save the invocation, the function has already been invoked and runs to return either the 200 or the 429 regardless, so it doesnt address the burned-invocation cost thats the stated harm. s1 (per-instance rate limit): agreed, its a stopgap. already flagged in the code comment and the PR body that notifying from telemetry's /blog-mql (which already verifies the recaptcha token) is the real fix and deletes this endpoint. tracked as a follow-up. tsc clean, test:unit 46/46, next lint clean. |
Claude Review Skill: Iteration 5At 🚦 Verdict: 💬 COMMENT / 0 blocking
✅ Iteration 4 verifiedT3 fixed, correctly. T4: you were right, I was wrong. I suggested stripping T5 and T6 remain open as optional nits, unchanged. 🟢 NitF1. The stated rationale does not match the strip set. The comment now argues 💡 SuggestionS1 (carried). The per-instance rate limit still scales the wrong way, since Vercel adds instances under load. Fine as a stopgap. Notifying from telemetry's 🎉 Praise
|
The comment justified keeping _ and ~ as 'atext, so stripping corrupts real data' — but |, * and backtick are atext too and are stripped, so the stated rule contradicted the code. Reframe on the real two-sided distinction: | * backtick each inject on their own (link/bold/code span), while _ ~ can only do cosmetic matched-pair styling once < > | and newlines are gone AND show up in real lead data (emails, UTM URLs). Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
|
thanks for the iteration 5 pass @amaan-bhati. fixed f1 in df949a1. you nailed the flaw in the old comment, the "atext so keep" line proved too much since |, * and backtick are atext too and we strip all three. rewrote it on the real split: | * and backtick each inject on their own (link, bold, code span) so they go, while _ and ~ can only do cosmetic matched pair styling once < > | and newlines are already gone, and they actually turn up in real lead data (email local parts, utm page urls like utm_source) so stripping them would corrupt leads. s1 is fair, the per instance limit does scale the wrong way as vercel adds instances. agreed the real fix is notifying from telemetry /blog-mql where the recaptcha token is already verified, which deletes this endpoint entirely. keeping it as the stopgap for this pr and it stays flagged as a follow up in the header. and thanks for the note on t4, glad pushing back with the rfc was the right move rather than reintroducing the exact bug the round before had just fixed. |
Claude Review Skill: Iteration 6Head: 🚦 Verdict: 💬 COMMENT / 0 blocking
✅ Iteration 5 verifiedF1 fixed, and the rewrite is better than what I asked for. The comment now leads with the real test rather than the atext one, and splits it into risk and cost per character. Stating why S1 acknowledged and kept flagged in the header as a follow-up. Agreed on the disposition. 🟡 N1. The
|
| Check | Result |
|---|---|
test:unit |
46/46 pass |
tsc --noEmit |
Clean |
| Rate limit boundary | 5 allowed, 6th is 429; matches the test and the RATE_LIMIT_MAX = 5 comment |
| Chat 4096-char limit | Worst case is ~1.1 KB across the caps (120+254+160+500 + labels), so the cap test's premise holds |
finally { clearTimeout(timer) } |
Reached on every path including the early !chatRes.ok return; no leaked timer |
Sanitizer claim, "_/~ can only do cosmetic matched-pair styling" |
Holds. Field forgery needs a newline and link forgery needs <, > or ` |
| Fetch error contents | Carries no URL or query params, so N1's fix does not risk the webhook secret |
| Branch state | 0 behind origin/main, 12 ahead |
Nothing here blocks. N1 is the one I would take, since it protects the same property the non-OK branch already argues for.
Iteration 6: local end-to-end verificationRan the endpoint for real rather than reading it, since the last two rounds were static analysis.
Results
Sink recorded 10 hits total across the session, which matches 1 warm-up + #5 + #6 + 2x #7/#8 + 5 from the flood. The What actually arrived in the Chat messageValid lead: Sent as Injection attempt. Sent: {"fullName":"Evil*bold*\n*Email:* attacker@evil.com\n*Company:* Forged",
"email":"a_b~c@x.com",
"companyName":"<https://evil.com|CLICK ME>",
"page":"/p?a=`code`&b=*x*",
"source":"admin-override"}Arrived: Every vector is dead in a way that's now observable rather than argued:
N1 confirmed: the production gap is real
Same code, one env var apart. In production a Chat 500 is visible and a DNS failure, refused connection, TLS error or abort is completely silent, while both return N3 confirmed
The UnchangedVerdict stays 💬 COMMENT, 0 blocking. The abuse controls, sanitizer, source pinning, caps and fail-open all behave exactly as documented under real requests. N1 moves from reasoned to reproduced; N2 (the 15s abort sitting at or above the platform ceiling) is the one thing here I can't demonstrate locally, since it needs Vercel's function timeout to bite. |
amaan-bhati
left a comment
There was a problem hiding this comment.
@dhananjay6561 Have added a comment for the recaptcha test failure, explained properly in the comment, kindly address. Rest of the things lgtm, this is the only thing i am concerned about, Rest all LGTM!
| await page.waitForLoadState('domcontentloaded'); | ||
|
|
||
| const form = page.locator('form', { has: page.getByPlaceholder('Full Name') }).first(); | ||
| await form.scrollIntoViewIfNeeded(); |
There was a problem hiding this comment.
@dhananjay6561 I think that there is a failing test, I think it is realted to the recaptcha failure, this is although not really possible to test locally, since there wont be any recaptcha token generated locally, we can only test this on prod, but i want to understand why gate with recaptcha here? Did we have this earlier on the newsletter or not? Why include recaptcha here? Do try and ideate with claude on this, lmk what you think about this. If not relevant anymore then also let me know.
amaan-bhati
left a comment
There was a problem hiding this comment.
Verified things on local end-to-end
Ran the endpoint for real rather than reading it, since the last two rounds were static analysis. df949a1, isolated worktree, next dev on :3111.
GOOGLE_CHAT_WEBHOOK_URL pointed at a local sink on :4599 that echoes each payload, so no traffic left the machine and no real Chat space was touched. Endpoint is /blog/api/blog-lead-notify (the basePath: '/blog', which subscribe-newsletter.tsx:137 gets right via router.basePath).
Results
| # | Request | Response | Delivered? |
|---|---|---|---|
| 1 | GET |
405 + Allow: POST |
no |
| 2 | POST {} |
400 {"ok":false,"error":"validation"} |
no |
| 3 | POST invalid email |
400 {"ok":false,"error":"validation"} |
no |
| 4 | POST + company_website (honeypot) |
200 {"ok":true} |
no |
| 5 | valid lead | 200 {"ok":true} |
yes |
| 6 | injection payload | 200 {"ok":true} |
yes, neutralized |
| 7 | a*b@x.com |
200 {"ok":true} |
yes, see N3 |
| 8 | 300-char name | 200 {"ok":true} |
yes, capped to exactly 120 |
| 9-16 | 8 identical POSTs | 200 x5, then 429 x3 |
5 |
Sink recorded 10 hits total across the session, which matches 1 warm-up + #5 + #6 + 2x #7/#8 + 5 from the flood. The 405, both 400s and the honeypot contributed zero, so the "silently accept and drop" claim holds at the wire level, not just in the unit test.
What actually arrived in the Chat message
Valid lead:
*📨 New Keploy blog subscriber*
*Name:* Amaan Bhati
*Email:* amaan.bhati@keploy.io
*Company:* Keploy
*Source:* blog-newsletter
*Page:* /blog/technology/api-testing?utm_source=q3_launch
*Submitted:* 2026-08-20T16:20:52.190Z
Sent as Amaan.Bhati@Keploy.IO, delivered lowercased. utm_source=q3_launch kept its underscore, which is the F1 decision doing its job on a real request.
Injection attempt. Sent:
{"fullName":"Evil*bold*\n*Email:* attacker@evil.com\n*Company:* Forged",
"email":"a_b~c@x.com",
"companyName":"<https://evil.com|CLICK ME>",
"page":"/p?a=`code`&b=*x*",
"source":"admin-override"}Arrived:
*📨 New Keploy blog subscriber*
*Name:* Evil bold Email: attacker@evil.com Company: Forged
*Email:* a_b~c@x.com
*Company:* https://evil.com CLICK ME
*Source:* blog-newsletter
*Page:* /p?a= code &b= x
*Submitted:* 2026-08-20T16:20:52.224Z
Every vector is dead in a way that's now observable rather than argued:
- Field forgery collapsed onto one line. The forged
*Email:*/*Company:*labels lost their*and their newlines, so they read as inert text inside the Name value and cannot be mistaken for real fields. - Fake link
<https://evil.com|CLICK ME>lost<,>and|, so it renders as plain text, not a clickable label. - Code span and bold both defused.
source: "admin-override"was pinned back toblog-newsletter.a_b~c@x.comsurvived intact, both the_and the~. This is the exact case F1's comment argues for, and it is now demonstrated rather than reasoned about.
N1 confirmed: the production gap is real
next dev forces NODE_ENV=development, so I drove the handler directly with a mocked req/res (the shape tests/lib/blog-lead-notify.test.ts already uses) and flipped only NODE_ENV:
================= NODE_ENV=development =================
network failure (catch branch) HTTP 200 logged: YES -> [blog-lead] delivery to Google Chat failed...
HTTP 500 (!chatRes.ok branch) HTTP 200 logged: YES -> [blog-lead] Google Chat rejected the message (HTTP 500)...
================= NODE_ENV=production ==================
network failure (catch branch) HTTP 200 logged: *** NOTHING ***
HTTP 500 (!chatRes.ok branch) HTTP 200 logged: YES -> [blog-lead] Google Chat rejected the message (HTTP 500)...
Same code, one env var apart. In production a Chat 500 is visible and a DNS failure, refused connection, TLS error or abort is completely silent, while both return 200 to the user either way. That is the asymmetry N1 describes, and the fail-open behaviour is confirmed correct on both paths.
N3 confirmed
a*b@x.com passes EMAIL_RE (* is neither @ nor whitespace), then sanitize turns the * into a space:
*Email:* a b@x.com
The *Email:* line is delivering something that is not an email address. Validating the sanitized value instead would have returned a 400. Rare input, one line to make impossible, and unrelated to the F1 question of which characters to strip.
Address iteration-6 review on the gchat notify endpoint: - N1: the catch branch was gated on NODE_ENV != production, so a network failure (DNS / refused / TLS / abort) went completely silent in prod while the adjacent !chatRes.ok branch logged. Log both in prod now, with a fixed PII-free message (no raw err, so the webhook secret in the URL can't leak) and a timed-out vs network-error distinction. - N2: the 15s abort could sit at/above the inherited platform function ceiling, so the platform could kill the function before the abort fired. Set maxDuration=10 explicitly (matching revalidate.ts) and drop the abort to 5s so the two numbers are chosen together with headroom. - N3: validate the email AFTER sanitizing, so an address that only passes EMAIL_RE raw (a*b@x.com) 400s instead of delivering a broken *Email:* line. - N4/N5: document that counting a rate-limited hit is deliberate, and correct the map-cap comment that overclaimed it can't grow unbounded. Adds tests for the prod network-failure log (no secret leak) and the validate-after-sanitize 400. Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
|
@amaan-bhati dug into the recaptcha thing, few things: first, there isn't a failing test on the current head. the e2e (chromium) check is green on df949a1, the exact commit you reviewed, all four LeadCapture tests pass headless in CI. if you saw a red run it was on an earlier commit or a flake, current state is clean. on "can't test locally without a real token": that's actually not needed here. the spec stubs grecaptcha in beforeEach, it fulfills the loader with an empty script and injects execute to return 'e2e-stub-token', so the token is a fake and the whole thing runs fully offline. that's why it passes in CI with no prod and no real google call. so it is testable, it's just stubbed rather than live. on why recaptcha is here at all: it's not from this pr. it came in with #403 (the /blog-mql lead capture), already on main. that's what gates the lead path, client mints an invisible token and telemetry verifies it server side. this pr doesn't touch any recaptcha code, and the chat notify path i added doesn't use recaptcha at all, the notify fetch fires outside the getToken().then() so it carries no token. i only extended the existing #403 spec to also cover the notify call. so nothing to fix here re recaptcha, it's pre-existing and still relevant to the blog-mql lead, just unrelated to what this pr changes. separately, pushed 253920b addressing the iteration-6 review: log silent notify failures in prod too (n1), maxDuration=10 + 5s abort so they're picked together (n2), validate email after sanitizing (n3), and documented the rate-limit hit counting + map cap (n4/n5). lmk if you want me to walk through any of it. |
|
@amaan-bhati verified the two fixes the same way you did, drove the handler with a mocked req/res and flipped only NODE_ENV, against the patched code in 253920b. n1, the prod gap you found is closed: the prod catch branch that logged *** NOTHING *** in your run now logs, symmetric with the !chatRes.ok branch. the raw error is only attached in dev, so prod stays PII-free, checked the log for the webhook key/token and the full url, none of it leaks. n3, a*b@x.com now gets rejected instead of delivered as 'a b@x.com': both behaviours are also locked into tests/lib/blog-lead-notify.test.ts so they can't regress (48/48 pass). n2 is the one that still can't be shown locally since it needs vercel's function timeout to bite, but maxDuration=10 now sits explicitly above the 5s abort so they're chosen together. |
Verified locally at
|
| Finding | Status |
|---|---|
| N1 catch branch silent in production | ✅ fixed, verified in both envs |
N3 EMAIL_RE before sanitize |
✅ fixed, verified, plus a test |
| N4 rate-limited request records a hit | ✅ documented as deliberate |
| N5 map cap exceedable within a window | ✅ documented, follow-up named |
| N2 abort vs function ceiling | ❌ abort fixed, but the maxDuration export is inert |
N1 and N3 are properly done. The one thing below needs fixing before I approve.
⛔ The blocker: export const maxDuration = 10 is silently ignored here
This is a Pages Router API route. In Next 14.0.1 the bare export is read for app router only; for pages it comes solely from export const config = {…}. Next's source is explicit: AUTHORIZED_EXTRA_ROUTER_PROPS is read from the bare export when pageType === "app", and from config when pageType === "pages".
Confirmed against the extractor on this exact file:
pageType="pages" as written (bare export) extraConfig = {}
pageType="pages" with export const config = {} extraConfig = {"maxDuration":10}
pageType="app" as written (bare export) extraConfig = {"maxDuration":10}
-export const maxDuration = 10;
+export const config = { maxDuration: 10 };To be clear about the severity: no request fails today. The abort is 5s and every Vercel plan's default ceiling is 10s or more, so "the abort trips before the platform kills the invocation" happens to hold anyway. What's wrong is that the comment above it states a guarantee that isn't actually in force, and the whole point of N2 was to make that guarantee explicit. If the abort is ever raised trusting the declared 10s, the catch stops logging again, which is precisely the silent-failure mode N1 just fixed. That's why I'd rather it be real than incidental.
Verified after applying it: 48/48 tests still pass, tsc clean.
Two nits, take or leave
name still has the pre-N3 ordering. The fix landed for email but not the field beside it, so a required field can arrive blank:
fullName "***" HTTP 200 "*Name:* "
fullName "<>|`" HTTP 200 "*Name:* "
!name passes on the raw value, then sanitize maps every character to a space and collapses it away. No injection risk, just the same ordering one field over.
- const name = String(data.fullName ?? "").trim();
+ // Sanitize before validating, same reason as the email below: sanitize() maps
+ // < > | * ` and newlines to spaces, so a raw-passing value like "***" would
+ // collapse to "" and ship a blank *Name:* line despite the non-empty check.
+ const name = sanitize(String(data.fullName ?? ""), 120);
...
const lead = {
- name: sanitize(name, 120),
+ name,With that applied:
fullName "***" HTTP 400 (not delivered)
fullName "<>|`" HTTP 400 (not delivered)
fullName " Jane " HTTP 200 "*Name:* Jane"
fullName "Ann O'Reilly" HTTP 200 "*Name:* Ann O'Reilly"
300-char name HTTP 200 capped at 120
The apostrophe case is deliberate: ' isn't in the strip set, so real names are unaffected.
The catch comment says "the 15s abort" while the abort is now 5000 (lines 234 vs 201). Introduced by this commit.
The evidence on what's already fixed
N1, production observability. All three failure modes now surface in production, the abort is distinguished from a network error, and the webhook secret never reaches the log:
=== NODE_ENV=development ===
network HTTP 200 logged=YES secretLeak=no
"[blog-lead] delivery to Google Chat failed (network error) — verify GOOGLE_CHAT_WEB…"
abort HTTP 200 logged=YES secretLeak=no
"[blog-lead] delivery to Google Chat failed (timed out) — verify GOOGLE_CHAT_WEBHOOK…"
http500 HTTP 200 logged=YES secretLeak=no
=== NODE_ENV=production ===
network HTTP 200 logged=YES secretLeak=no
abort HTTP 200 logged=YES secretLeak=no
http500 HTTP 200 logged=YES secretLeak=no
Production previously logged nothing for the network and abort cases. Fail-open is intact, every path still returns 200. I asserted against the literal key= / token= values in the webhook URL rather than just the message shape, so the no-leak result is a real check.
N3, sanitize before validate.
email a*b@x.com HTTP 400 (not delivered)
email a_b~c@x.com HTTP 200 "*Email:* a_b~c@x.com"
email A.B@Keploy.IO HTTP 200 "*Email:* a.b@keploy.io"
The mangled-address case 400s instead of delivering a b@x.com, _/~ still survive per the earlier decision, and server-side lowercasing still happens.
Happy to approve as soon as the export const config line lands.
…efore validate export const maxDuration is inert for pages router routes in next 14 — the static-info extractor only reads the bare export for app router and reads it from config for pages, so move it into export const config so the declared ceiling is actually applied. also sanitize fullName before the non-empty check (matching the email path) so a value like "***" that collapses to empty after sanitize is rejected rather than shipping a blank name line, and fix a stale 15s abort comment (now 5s). Signed-off-by: dhananjay6561 <dhananjayaggarwal6561@gmail.com>
amaan-bhati
left a comment
There was a problem hiding this comment.
Re-checked at 4542438. The blocker is unchanged
The two new commits are a main merge (which brought in #416), so nothing here addresses the previous round. Re-verified on this head rather than assuming:
The blocker, still present. blog-lead-notify.ts:42 is still the bare export, and Next still ignores it for a Pages Router route:
pageType="pages" extraConfig = {}
pageType="app" extraConfig = {"maxDuration":10}
-export const maxDuration = 10;
+export const config = { maxDuration: 10 };Line 198's comment still promises maxDuration (10s) is in force. It isn't.
Both nits also unchanged. name still validates raw and delivers sanitized (:155 / :171):
fullName '***' HTTP 200 "*Name:* "
fullName '<>|`' HTTP 200 "*Name:* "
fullName 'Jane' HTTP 200 "*Name:* Jane"
And :234 still reads "the 15s abort" while the abort is 5000.
The merge itself is clean: npm run test:unit 52/52 (up from 48, picking up #416's cases), npx tsc --noEmit clean. CI: build, E2E chromium, DCO pass; lighthouse still running.
Same as before — happy to approve once the export const config line lands. The other two are optional.
|
hey you're looking at 4542438 which is the main merge commit, one behind. the actual fix is in 8f4ff8b sitting right on top of it and it's already on the remote head. can you take a pull and recheck at 8f4ff8b? all three are in there: line 47 is now export const config = { maxDuration: 10 } with a note above explaining the pages vs app extractor thing so it doesn't creep back, name is sanitized before validate at line 165, and the stale 15s comment is now 5s at line 244. if you run the extractor again at that head you'll get extraConfig with maxDuration for pageType pages. the mixup is just timing, the main merge got pushed to the branch while the last round was open and my fix went on after it, so it's the newest commit. fetch and you should see all three resolved. |
amaan-bhati
left a comment
There was a problem hiding this comment.
Approving. Full local verification at 8f4ff8b
Verified the whole surface, not just the last delta. Isolated worktree, next dev, a local sink on :4601 standing in for the Chat webhook so nothing left the machine and no real space was touched, plus headless Chromium for the UI path.
npm run test:unit 52/52 · npx tsc --noEmit clean · CI green on this head: build, lighthouse, E2E chromium, DCO.
Rejection paths — nothing delivered
GET (wrong method) 405 {"ok":false,"error":"method_not_allowed"}
POST {} 400 {"ok":false,"error":"validation"}
POST invalid email 400 {"ok":false,"error":"validation"}
POST fullName "***" 400 {"ok":false,"error":"validation"}
POST email a*b@x.com 400 {"ok":false,"error":"validation"}
POST honeypot filled 200 {"ok":true}
sink hits from all six: 0
The two 400s in the middle are the validate-after-sanitize ordering on both fields: each would previously have passed validation and delivered a mangled value.
Delivery paths — what the sink actually received
*Name:* Amaan Bhati
*Email:* amaan.bhati@keploy.io <- sent Amaan.Bhati@Keploy.IO
*Page:* /blog/technology/x?utm_source=q3_launch <- underscore preserved
Injection attempt, sent with forged *Email:* / *Company:* lines containing real newlines, a <url|label> link, a code span, bold, and source: "admin-override":
*Name:* Evil bold Email: fake@evil.com Company: Forged <- collapsed inline, no forged fields
*Email:* a_b~c@x.com <- '_' and '~' both survive
*Company:* https://evil.com CLICK <- link defused
*Page:* /p?a= code &b= x <- backtick + bold stripped
*Source:* blog-newsletter <- forged source pinned
Caps, measured on the delivered payload:
Name delivered length = 120 (sent 300)
Page delivered length = 500 (sent 600)
Abuse controls
req 1..5 -> 200 {"ok":true}
req 6 -> 429 {"ok":false,"error":"rate_limited"}
req 7 -> 429 {"ok":false,"error":"rate_limited"}
Observability, both envs
next dev forces development, so I drove the handler directly with a mocked req/res and flipped only NODE_ENV:
NODE_ENV=development
network HTTP 200 logged=YES secretLeak=no "…failed (network error) — verify GOOGLE…"
abort HTTP 200 logged=YES secretLeak=no "…failed (timed out) — verify GOOGLE_CHA…"
http500 HTTP 200 logged=YES secretLeak=no "…rejected the message (HTTP 500) — …"
NODE_ENV=production
network HTTP 200 logged=YES secretLeak=no
abort HTTP 200 logged=YES secretLeak=no
http500 HTTP 200 logged=YES secretLeak=no
All three visible in production, abort distinguished from network error, fail-open intact on every path. I asserted against the literal key= / token= values in the webhook URL, not just the message shape, so secretLeak=no is a real check.
Function ceiling
pageType="pages" extraConfig = {"maxDuration":10}
:47 export const config = { maxDuration: 10 };
:211 const timer = setTimeout(() => controller.abort(), 5000);
Read back through Next's own static-info extractor, so the 10s ceiling is genuinely in force and the 5s abort sits under it — the catch can run and log before the platform reclaims the invocation.
UI double-submit guard
same-tick x3 (the race the ref guards) POSTs = 1
sequential x3, 400ms apart POSTs = 2 gap = 888ms
The same-tick case is the one the ref exists for and it holds at exactly one POST. The sequential case is an in-flight guard behaving correctly — the second POST landed 888ms later, after the first had settled. Worth noting I could not verify whether the success state locks the form, because the newsletter subscription host isn't reachable from this environment, so subscribed never flips locally. In production that path is what would swap the UI.
Everything from the six rounds is closed and verified: the sanitizer contract and its _/~ decision, source pinning, honeypot, per-IP rate limit, field caps, fail-open, production observability, validate-before-sanitize on both fields, and the function-ceiling guarantee now being real rather than incidental. Approving.
nehagup
left a comment
There was a problem hiding this comment.
Review — newsletter form → Google Chat notify
Recommendation: Approve with nits. Well-engineered and hardened across the review iterations; the security-critical items all pass at head, and CI is green (E2E / build / Lighthouse / DCO).
Security / abuse — passes
- No committed secret — the webhook is
process.env.GOOGLE_CHAT_WEBHOOK_URL(neverNEXT_PUBLIC_*);.env.local.exampleships it commented-out as a placeholder. Error logs are PII- and secret-free. - Endpoint is defended — honeypot, per-IP rate limit (5/min, with correct
x-real-ip/ last-XFFhandling against spoofing), field caps, afetchtimeout, and fail-open so a Chat outage never blocks a subscription. User input is sanitized before it enters the Chat card.
Should-fix
- File the durable fix as a tracked issue, not a
// follow-up. The rate limiter is in-memory per serverless instance, so its effective ceiling rises as Vercel scales out under load. The real mitigation — send this notification server-to-server from the already-reCAPTCHA-verified/blog-mqlpath and delete this public endpoint — is only referenced in comments. Reasonable stopgap for an internal-channel notifier, but "follow-up" should be a real ticket.
Process — worth reconciling before merge
The PR shows an APPROVED decision, but every formal review on the thread is COMMENTED, and the last one explicitly held approval pending the maxDuration fix. That fix landed in a later commit, but no re-approval was submitted afterward. Worth confirming the final commit is actually approved before merging on the strength of that state.
Nits
- Rate-map eviction only prunes expired IPs above a 10k-entry threshold, so >10k IPs rotated within one 60s window could grow the map unbounded for the instance lifetime — add a hard cap / LRU if the
/blog-mqlrefactor slips. - Autolinking of bare URLs in the
name/companyfields inside the Chat message is by design — no deceptive-label vector (<,>,|are stripped) — noted for the record, no action.
Scope
Clean — 517 of the 628 additions are the handler + 48 unit tests; the client change is small and entirely in service of the feature.
Related (not for this PR): this endpoint is better protected than landing's /trial/submit, which still lacks a honeypot / rate limit — worth backporting these defenses there.
🤖 Assisted review via Claude Code.

Forwards every blog newsletter/lead form submission to a Google Chat space, so the team gets pinged in real time when a new lead comes in. Same approach we already use for the request-a-trial form on the landing repo.
Where the form data goes today
The newsletter form (under the ad banner on every blog post) already sends data to two places, both MongoDB. This PR does not change either of them:
subscription(guestInput)mutation, lands in thesubscriptionscollection (upsert by email, so repeat emails get merged). This is the actual subscriber list.POST /blog-mql, verified server-side via reCAPTCHA Enterprise, lands in thekeploy-telemetry.blog_mqlcollection (one row per submit, for lead tracking).So the data is stored, but nobody gets notified when a new lead arrives. That's the gap this PR fills.
What changed
pages/api/blog-lead-notify.tsthat holds the webhook secret and posts a formatted message to the space.subscribe-newsletter.tsxcalls it fire-and-forget, alongside the existing subscription + blog-mql capture (does not touch or gate either)..env.local.example.Action needed to turn it on
GOOGLE_CHAT_WEBHOOK_URLin the blog-website Vercel project (notNEXT_PUBLIC_*, so it never reaches the browser).Notes
blog-mql.ts; logs gated onNODE_ENV./trial/submit, which is also a public unauthenticated POST. Stronger gating via reCAPTCHA Enterprise (forwarding + verifying the token the client already mints) needs the GCP project-id + api-key secrets that live in telemetry, not this repo — tracked as a follow-up rather than half-gated here.