Skip to content

Commit 48cd8fc

Browse files
fix: address latest captcha review feedback
- Add bounded hCaptcha widget-ready wait (15s) to avoid indefinite pending chat requests when the third-party script is blocked or never loads - Pass the request cancellation token into stream endpoint captcha error writes to stop writing to aborted responses on client disconnect - Emit chat hCaptcha site key to the client only when both SecretKey and SiteKey are configured, keeping client/server captcha enablement aligned
1 parent b764b8d commit 48cd8fc

3 files changed

Lines changed: 25 additions & 6 deletions

File tree

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,13 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
134134
if (captchaValid is null)
135135
{
136136
Response.StatusCode = 503;
137-
await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, CancellationToken.None);
137+
await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken);
138138
return;
139139
}
140140
if (!captchaValid.Value)
141141
{
142142
Response.StatusCode = 403;
143-
await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, CancellationToken.None);
143+
await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken);
144144
return;
145145
}
146146

EssentialCSharp.Web/Views/Shared/_Layout.cshtml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@
184184
var buildLabel = ReleaseDateAttribute.GetReleaseDate() is DateTime date
185185
? TimeZoneInfo.ConvertTimeFromUtc(date, TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")).ToString("d MMM, yyyy h:mm:ss tt", CultureInfo.InvariantCulture)
186186
: null;
187+
var chatCaptchaSiteKey = !string.IsNullOrWhiteSpace(_CaptchaOptions.Value.SecretKey)
188+
&& !string.IsNullOrWhiteSpace(_CaptchaOptions.Value.SiteKey)
189+
? _CaptchaOptions.Value.SiteKey
190+
: null;
187191
}
188192
window.PERCENT_COMPLETE = @Json.Serialize(percentComplete);
189193
window.PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage);
@@ -194,7 +198,7 @@
194198
window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]);
195199
window.BUILD_LABEL = @Json.Serialize(buildLabel);
196200
window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity"));
197-
window.HCAPTCHA_SITE_KEY = @Json.Serialize(_CaptchaOptions.Value.SiteKey);
201+
window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey);
198202
</script>
199203
<script src="~/dist/assets/site-shell.js" type="module" asp-append-version="true"></script>
200204
</body>

EssentialCSharp.Web/wwwroot/js/chat-module.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,25 @@ let captchaTokenResolve = null;
2020
let captchaTokenReject = null;
2121
let captchaPending = false; // prevents concurrent token requests overwriting promise callbacks
2222
let captchaRequestGeneration = 0;
23+
const CAPTCHA_WIDGET_READY_TIMEOUT_MS = 15_000;
2324

24-
// Resolves once the widget has rendered. getCaptchaToken() awaits this so a user who
25-
// submits before hCaptcha finishes loading waits (up to 15 s) rather than getting a 403.
25+
// Resolves once the widget has rendered.
2626
let captchaWidgetReadyResolve = null;
2727
const captchaWidgetReady = captchaSiteKey
2828
? new Promise((resolve) => { captchaWidgetReadyResolve = resolve; })
2929
: Promise.resolve();
3030

31+
function awaitCaptchaWidgetReady() {
32+
if (!captchaSiteKey) return Promise.resolve();
33+
if (captchaWidgetId !== null) return Promise.resolve();
34+
35+
const timeoutPromise = new Promise((_, reject) => {
36+
setTimeout(() => reject(new Error('captcha-unavailable')), CAPTCHA_WIDGET_READY_TIMEOUT_MS);
37+
});
38+
39+
return Promise.race([captchaWidgetReady, timeoutPromise]);
40+
}
41+
3142
function initCaptchaWidget() {
3243
if (!captchaSiteKey) return;
3344
// Guard: only render once (widget lives outside the v-if dialog overlay)
@@ -67,6 +78,7 @@ function initCaptchaWidget() {
6778
* Returns a fresh hCaptcha token, or null if captcha is not configured.
6879
* Waits for the widget to finish rendering if it has not yet (handles slow script loads).
6980
* Rejects with 'captcha-concurrent' if a token request is already in-flight.
81+
* Rejects with 'captcha-unavailable' if the widget never becomes ready.
7082
* Rejects with 'captcha-expired' or 'captcha-error' via hCaptcha's own callbacks.
7183
*/
7284
function getCaptchaToken() {
@@ -77,7 +89,7 @@ function getCaptchaToken() {
7789

7890
// Chain onto captchaWidgetReady so calls made before the widget finishes loading
7991
// wait rather than immediately returning null and causing a 403.
80-
return captchaWidgetReady.then(() => {
92+
return awaitCaptchaWidgetReady().then(() => {
8193
if (requestGeneration !== captchaRequestGeneration) {
8294
captchaPending = false;
8395
return Promise.reject(new Error('captcha-stale'));
@@ -94,6 +106,9 @@ function getCaptchaToken() {
94106
};
95107
window.hcaptcha.execute(captchaWidgetId);
96108
});
109+
}).catch((error) => {
110+
captchaPending = false;
111+
throw error;
97112
});
98113
}
99114

0 commit comments

Comments
 (0)