Skip to content

Commit 37ffe85

Browse files
fix: captcha dev bypass and HTTP/2 connection header
- ChatController: inject IOptions<CaptchaOptions>; skip captcha check entirely when SiteKey is not configured (local dev without hCaptcha secrets) - ChatController: wrap CaptchaService.VerifyAsync in try-catch to fail-open on InvalidOperationException (missing SecretKey) - ChatController: remove Response.Headers.Connection = keep-alive (invalid in HTTP/2, generated ASP.NET warnings) - chat-module.js: getFreshCaptchaToken returns null (not throws) when HCAPTCHA_SITE_KEY is falsy - chat-module.js: fetchChatStream omits captchaToken from body when null so server bypass fires correctly feat: add hCaptcha test keys for local development Use official hCaptcha test keypair (https://docs.hcaptcha.com/#integration-testing-test-keys) in appsettings.Development.json so all devs get working captcha out of the box without configuring secrets. Test keys always pass silently no challenge is shown. - SiteKey: 10000000-ffff-ffff-ffff-000000000001 - SecretKey: 0x0000000000000000000000000000000000000000 These are public constants from hCaptcha docs; committing them is intentional and safe. Production keys must be set via 'aspire secret set' and will override these defaults. fix: remove unsafe captcha bypass Now that appsettings.Development.json has official hCaptcha test keys, the 'skip when SiteKey not configured' bypass is both unnecessary and dangerous a misconfigured production deploy would silently allow all requests. - ChatController: remove IOptions<CaptchaOptions> injection and SiteKey bypass block - ChatController: remove try-catch around VerifyAsync (InvalidOperationException from missing SecretKey should surface as 500, not be silently swallowed with fail-open) - chat-module.js: remove null-return bypass in getFreshCaptchaToken - chat-module.js: restore direct captchaToken in fetchChatStream body If hCaptcha is misconfigured in production: server: throws InvalidOperationException -> 500 (loud, ops must fix) client: throws 'Captcha is not configured.' -> shows error to user (not silent)
1 parent 8b1f1bf commit 37ffe85

5 files changed

Lines changed: 186 additions & 23 deletions

File tree

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Text.Json;
22
using EssentialCSharp.Chat.Common.Services;
3+
using EssentialCSharp.Web.Services;
34
using Microsoft.AspNetCore.Authorization;
45
using Microsoft.AspNetCore.Mvc;
56
using Microsoft.AspNetCore.RateLimiting;
@@ -13,17 +14,22 @@ namespace EssentialCSharp.Web.Controllers;
1314
public class ChatController : ControllerBase
1415
{
1516
private readonly IAIChatService _AiChatService;
17+
private readonly ICaptchaService _CaptchaService;
1618
private readonly ILogger<ChatController> _Logger;
1719

18-
public ChatController(ILogger<ChatController> logger, IAIChatService aiChatService)
20+
public ChatController(ILogger<ChatController> logger, IAIChatService aiChatService, ICaptchaService captchaService)
1921
{
2022
_AiChatService = aiChatService;
23+
_CaptchaService = captchaService;
2124
_Logger = logger;
2225
}
2326

2427
[HttpPost("message")]
2528
public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default)
2629
{
30+
var (captchaOk, captchaError) = await VerifyCaptchaAsync(request.CaptchaToken, cancellationToken);
31+
if (!captchaOk) return captchaError!;
32+
2733
request.Message = request.Message.Trim();
2834
if (string.IsNullOrEmpty(request.Message))
2935
return BadRequest(new { error = "Message cannot be empty." });
@@ -49,6 +55,18 @@ public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest reque
4955
[HttpPost("stream")]
5056
public async Task StreamMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default)
5157
{
58+
// Captcha and input validation must happen before SSE headers are set,
59+
// so we can still return a proper HTTP status code on failure.
60+
var (captchaOk, captchaError) = await VerifyCaptchaAsync(request.CaptchaToken, cancellationToken);
61+
if (!captchaOk)
62+
{
63+
Response.StatusCode = captchaError is ObjectResult obj ? obj.StatusCode ?? 403 : 403;
64+
await Response.WriteAsJsonAsync(
65+
captchaError is ObjectResult { Value: not null } r ? r.Value : new { error = "Captcha verification failed." },
66+
CancellationToken.None);
67+
return;
68+
}
69+
5270
request.Message = request.Message.Trim();
5371
if (string.IsNullOrEmpty(request.Message))
5472
{
@@ -63,7 +81,6 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
6381

6482
Response.ContentType = "text/event-stream";
6583
Response.Headers.CacheControl = "no-cache";
66-
Response.Headers.Connection = "keep-alive";
6784

6885
try
6986
{
@@ -113,4 +130,32 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
113130
catch { /* client already disconnected */ }
114131
}
115132
}
133+
134+
/// <summary>
135+
/// Verifies the hCaptcha token. Fails-open on service outage (returns success with warning)
136+
/// since the endpoint is already protected by [Authorize] and rate limiting.
137+
/// </summary>
138+
private async Task<(bool Success, IActionResult? Error)> VerifyCaptchaAsync(
139+
string? captchaToken, CancellationToken cancellationToken)
140+
{
141+
if (string.IsNullOrWhiteSpace(captchaToken))
142+
return (false, StatusCode(StatusCodes.Status403Forbidden,
143+
new { error = "Captcha verification required.", errorCode = "captcha_required", retryable = true }));
144+
145+
var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString();
146+
var result = await _CaptchaService.VerifyAsync(captchaToken, remoteIp, cancellationToken);
147+
148+
if (result is null)
149+
{
150+
// hCaptcha service is unreachable — fail-open since [Authorize] + rate limiting still protect the endpoint.
151+
_Logger.LogWarning("hCaptcha service unavailable for user {User} — allowing request", User.Identity?.Name);
152+
return (true, null);
153+
}
154+
155+
if (!result.Success)
156+
return (false, StatusCode(StatusCodes.Status403Forbidden,
157+
new { error = "Captcha verification failed.", errorCode = "captcha_failed", retryable = true }));
158+
159+
return (true, null);
160+
}
116161
}

EssentialCSharp.Web/Controllers/ChatMessageRequest.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ public class ChatMessageRequest
1010
[StringLength(200)]
1111
public string? PreviousResponseId { get; set; }
1212
public bool EnableContextualSearch { get; set; } = true;
13-
public string? CaptchaResponse { get; set; } // For future captcha implementation
13+
14+
[StringLength(4096)]
15+
public string? CaptchaToken { get; set; }
1416
}

EssentialCSharp.Web/Views/Shared/_Layout.cshtml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,4 @@
192192
<script src="~/dist/assets/site-shell.js" type="module" asp-append-version="true"></script>
193193
</body>
194194
</html>
195+

EssentialCSharp.Web/appsettings.Development.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,9 @@
1111
},
1212
"SiteSettings": {
1313
"BaseUrl": "https://localhost:7184"
14+
},
15+
"HCaptcha": {
16+
"SiteKey": "10000000-ffff-ffff-ffff-000000000001",
17+
"SecretKey": "0x0000000000000000000000000000000000000000"
1418
}
1519
}

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

Lines changed: 131 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export function useChatWidget() {
1616
const chatInputField = ref(null);
1717
const lastResponseId = ref(null);
1818

19+
// hCaptcha invisible widget state
20+
const captchaContainerEl = ref(null);
21+
let captchaWidgetId = null;
22+
let captchaResolve = null;
23+
let captchaReject = null;
24+
1925
// Load chat history from localStorage on initialization
2026
function loadChatHistory() {
2127
try {
@@ -109,6 +115,67 @@ export function useChatWidget() {
109115
}
110116

111117
// Remove captcha callback functions as they're no longer needed for chat
118+
119+
// hCaptcha invisible widget — programmatic callbacks (not string-based data-callback attributes)
120+
function onCaptchaSuccess(token) {
121+
if (captchaResolve) {
122+
captchaResolve(token);
123+
captchaResolve = null;
124+
captchaReject = null;
125+
}
126+
}
127+
128+
function onCaptchaExpired() {
129+
if (captchaReject) {
130+
captchaReject(new Error('Captcha expired'));
131+
captchaResolve = null;
132+
captchaReject = null;
133+
}
134+
}
135+
136+
function onCaptchaError() {
137+
if (captchaReject) {
138+
captchaReject(new Error('Captcha error'));
139+
captchaResolve = null;
140+
captchaReject = null;
141+
}
142+
}
143+
144+
async function ensureCaptchaWidget() {
145+
if (!window.HCAPTCHA_SITE_KEY) throw new Error('Captcha is not configured.');
146+
await nextTick();
147+
if (!window.hcaptcha?.render) throw new Error('Captcha script is not ready.');
148+
if (captchaWidgetId !== null) return;
149+
150+
captchaWidgetId = window.hcaptcha.render(captchaContainerEl.value, {
151+
sitekey: window.HCAPTCHA_SITE_KEY,
152+
size: 'invisible',
153+
callback: onCaptchaSuccess,
154+
'expired-callback': onCaptchaExpired,
155+
'error-callback': onCaptchaError
156+
});
157+
}
158+
159+
async function getFreshCaptchaToken() {
160+
await ensureCaptchaWidget();
161+
162+
return await new Promise((resolve, reject) => {
163+
captchaResolve = resolve;
164+
captchaReject = reject;
165+
166+
window.hcaptcha.reset(captchaWidgetId);
167+
window.hcaptcha.execute(captchaWidgetId);
168+
169+
// Safety timeout — should not normally be reached
170+
setTimeout(() => {
171+
if (captchaReject) {
172+
captchaReject(new Error('Captcha timed out'));
173+
captchaResolve = null;
174+
captchaReject = null;
175+
}
176+
}, 15000);
177+
});
178+
}
112179
// The captcha service can still be used elsewhere in the application
113180

114181
function scrollToBottom() {
@@ -182,7 +249,24 @@ export function useChatWidget() {
182249
saveChatHistory();
183250
return;
184251
}
185-
252+
253+
// Acquire captcha token BEFORE mutating UI state — so if captcha fails the user
254+
// message is still in the input and nothing is incorrectly shown in the chat history.
255+
let captchaToken;
256+
try {
257+
captchaToken = await getFreshCaptchaToken();
258+
} catch (captchaErr) {
259+
console.warn('Captcha acquisition failed:', captchaErr);
260+
chatMessages.value.push({
261+
role: 'error',
262+
errorType: 'captcha-error',
263+
content: 'Security verification failed. Please refresh the page and try again.',
264+
timestamp: new Date().toISOString()
265+
});
266+
saveChatHistory();
267+
return;
268+
}
269+
186270
chatInput.value = '';
187271

188272
// Add user message
@@ -191,6 +275,7 @@ export function useChatWidget() {
191275
content: userMessage,
192276
timestamp: new Date().toISOString()
193277
});
278+
const userMessageIndex = chatMessages.value.length - 1;
194279

195280
// Save immediately after adding user message
196281
saveChatHistory();
@@ -207,25 +292,37 @@ export function useChatWidget() {
207292

208293
let reader = null;
209294
try {
210-
const requestBody = {
211-
message: userMessage,
212-
enableContextualSearch: true,
213-
previousResponseId: lastResponseId.value
214-
};
215-
216-
const response = await fetch('/api/chat/stream', {
217-
method: 'POST',
218-
headers: {
219-
'Content-Type': 'application/json',
220-
},
221-
body: JSON.stringify(requestBody)
222-
});
295+
const response = await fetchChatStream(userMessage, captchaToken);
223296

224297
if (!response.ok) {
225298
if (response.status === 401) {
226299
throw new Error('Authentication required');
300+
} else if (response.status === 403) {
301+
// Captcha failed — try once more with a fresh token
302+
let errorData = {};
303+
try { errorData = await response.json(); } catch (_) {}
304+
305+
if (errorData.retryable) {
306+
let retryToken;
307+
try {
308+
retryToken = await getFreshCaptchaToken();
309+
} catch (_) {
310+
throw new Error('captcha-failed');
311+
}
312+
313+
const retryResponse = await fetchChatStream(userMessage, retryToken);
314+
if (!retryResponse.ok) {
315+
// Remove optimistic user message and restore input
316+
chatMessages.value.splice(userMessageIndex, 1);
317+
chatInput.value = userMessage;
318+
throw new Error('captcha-failed');
319+
}
320+
// Use retry response for the rest of the streaming flow
321+
reader = retryResponse.body.getReader();
322+
} else {
323+
throw new Error('captcha-failed');
324+
}
227325
} else if (response.status === 429) {
228-
// Handle rate limiting - simple error message without captcha
229326
let errorData;
230327
try {
231328
errorData = await response.json();
@@ -237,19 +334,16 @@ export function useChatWidget() {
237334
}
238335

239336
const retryAfter = errorData.retryAfter || 60;
240-
const errorMessage = `Rate limit exceeded. Please wait ${Math.ceil(retryAfter)} seconds before sending another message.`;
241-
242-
throw new Error(errorMessage);
337+
throw new Error(`Rate limit exceeded. Please wait ${Math.ceil(retryAfter)} seconds before sending another message.`);
243338
} else if (response.status === 400) {
244-
// Handle validation errors
245339
const errorData = await response.json();
246340
throw new Error(errorData.error || 'Bad request');
247341
}
248342
throw new Error(`HTTP error! status: ${response.status}`);
249343
}
250344

251345
// Handle streaming response
252-
reader = response.body.getReader();
346+
if (!reader) reader = response.body.getReader();
253347
const decoder = new TextDecoder();
254348
let assistantMessage = '';
255349
let assistantMessageIndex = -1;
@@ -321,6 +415,9 @@ export function useChatWidget() {
321415
if (error.name === 'AbortError') {
322416
errorMessage = 'Request was cancelled. Please try again.';
323417
errorType = 'error';
418+
} else if (error.message === 'captcha-failed') {
419+
errorMessage = 'Security verification failed. Please try again.';
420+
errorType = 'captcha-error';
324421
} else if (error.message?.includes('Authentication required')) {
325422
errorMessage = 'You must be logged in to use the chat feature. Please log in and try again.';
326423
errorType = 'auth-error';
@@ -365,6 +462,19 @@ export function useChatWidget() {
365462
}
366463
}
367464

465+
function fetchChatStream(message, captchaToken) {
466+
return fetch('/api/chat/stream', {
467+
method: 'POST',
468+
headers: { 'Content-Type': 'application/json' },
469+
body: JSON.stringify({
470+
message,
471+
enableContextualSearch: true,
472+
previousResponseId: lastResponseId.value,
473+
captchaToken
474+
})
475+
});
476+
}
477+
368478
// Clean up old chat sessions (keep only last 7 days)
369479
function cleanupOldSessions() {
370480
try {
@@ -396,6 +506,7 @@ export function useChatWidget() {
396506
isTyping,
397507
chatMessagesEl,
398508
chatInputField,
509+
captchaContainerEl,
399510

400511
// Methods
401512
openChatDialog,

0 commit comments

Comments
 (0)