Skip to content

Commit eebb309

Browse files
committed
Devs who make use of reCAPTCHA V3 can / should now specify an action in the ValidateRecaptcha attribute. This will then also validate that the action does match the expected result.
1 parent 67c3a78 commit eebb309

File tree

5 files changed

+140
-21
lines changed

5 files changed

+140
-21
lines changed

docs/Griesoft.AspNetCore.ReCaptcha.xml

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

src/ReCaptcha/Filters/ValidateRecaptchaFilter.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ public ValidateRecaptchaFilter(IRecaptchaService recaptchaService, IOptionsMonit
3030

3131
public ValidationFailedAction OnValidationFailedAction { get; set; } = ValidationFailedAction.Unspecified;
3232

33+
public string? Action { get; set; }
34+
3335
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
3436
{
3537
if (OnValidationFailedAction == ValidationFailedAction.Unspecified)
@@ -73,7 +75,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE
7375
}
7476
private bool ShouldShortCircuit(ActionExecutingContext context, ValidationResponse response)
7577
{
76-
if (!response.Success)
78+
if (!response.Success || Action != response.Action)
7779
{
7880
_logger.LogInformation(Resources.InvalidResponseTokenMessage);
7981

src/ReCaptcha/ValidateRecaptchaAttribute.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
namespace Griesoft.AspNetCore.ReCaptcha
77
{
88
/// <summary>
9-
/// Validates an incoming POST request to a controller or action, which is decorated with this attribute
10-
/// that the header contains a valid ReCaptcha token. If the token is missing or is not valid, the action
11-
/// will not be executed.
9+
/// Validates an incoming request that it contains a valid ReCaptcha token.
1210
/// </summary>
11+
/// <remarks>
12+
/// Can be applied to a specific action or to a controller which would validate all incoming requests to it.
13+
/// </remarks>
1314
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
1415
public sealed class ValidateRecaptchaAttribute : Attribute, IFilterFactory, IOrderedFilter
1516
{
@@ -21,11 +22,18 @@ public sealed class ValidateRecaptchaAttribute : Attribute, IFilterFactory, IOrd
2122

2223
/// <summary>
2324
/// If set to <see cref="ValidationFailedAction.BlockRequest"/>, the requests that do not contain a valid reCAPTCHA response token will be canceled.
24-
/// If this is set to anything else than <see cref="ValidationFailedAction.Unspecified"/>, this will override the global behaviour,
25-
/// which you might have set at app startup.
25+
/// If this is set to anything else than <see cref="ValidationFailedAction.Unspecified"/>, this will override the global behavior.
2626
/// </summary>
2727
public ValidationFailedAction ValidationFailedAction { get; set; } = ValidationFailedAction.Unspecified;
2828

29+
/// <summary>
30+
/// The name of the action that is verified.
31+
/// </summary>
32+
/// <remarks>
33+
/// This is a reCAPTCHA V3 feature and should be used only when validating V3 challenges.
34+
/// </remarks>
35+
public string? Action { get; set; }
36+
2937

3038
/// <summary>
3139
/// Creates an instance of the executable filter.
@@ -42,6 +50,7 @@ public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
4250
_ = filter ?? throw new InvalidOperationException(Resources.RequiredServiceNotRegisteredErrorMessage);
4351

4452
filter.OnValidationFailedAction = ValidationFailedAction;
53+
filter.Action = Action;
4554

4655
return filter;
4756
}

tests/ReCaptcha.Tests/Filters/ValidateRecaptchaFilterTests.cs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,10 @@ public async Task OnActionExecutionAsync_WhenValidationFailed_ContinuesAndAddsRe
253253
}
254254

255255
[Test]
256-
public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsResponseToArguments()
256+
public async Task OnActionExecutionAsync_WhenActionDoesNotMatch_BlocksAndReturns_RecaptchaValidationFailedResult()
257257
{
258258
// Arrange
259+
var action = "submit";
259260
var httpContext = new DefaultHttpContext();
260261
httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue);
261262

@@ -270,8 +271,79 @@ public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsR
270271
.Verifiable();
271272

272273
_filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger);
274+
_filter.Action = action;
275+
276+
// Act
277+
await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate);
278+
279+
// Assert
280+
_recaptchaServiceMock.Verify();
281+
Assert.IsInstanceOf<IRecaptchaValidationFailedResult>(_actionExecutingContext.Result);
282+
}
283+
284+
[Test]
285+
public async Task OnActionExecutionAsync_WhenActionDoesNotMatch_ContinuesAndAddsResponseToArguments()
286+
{
287+
// Arrange
288+
var action = "submit";
289+
var httpContext = new DefaultHttpContext();
290+
httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue);
291+
292+
_actionExecutingContext.HttpContext = httpContext;
293+
294+
_recaptchaServiceMock = new Mock<IRecaptchaService>();
295+
_recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is<string>(s => s == TokenValue), null))
296+
.ReturnsAsync(new ValidationResponse
297+
{
298+
Success = true,
299+
ErrorMessages = new List<string> { "invalid-input-response" }
300+
})
301+
.Verifiable();
302+
303+
_filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger)
304+
{
305+
OnValidationFailedAction = ValidationFailedAction.ContinueRequest,
306+
Action = action
307+
};
308+
309+
_actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = true });
310+
311+
// Act
312+
await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate);
313+
314+
// Assert
315+
_recaptchaServiceMock.Verify();
316+
Assert.IsInstanceOf<OkResult>(_actionExecutingContext.Result);
317+
Assert.IsTrue((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Success);
318+
Assert.GreaterOrEqual((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.Count(), 1);
319+
Assert.AreEqual(ValidationError.InvalidInputResponse, (_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.First());
320+
}
321+
322+
[Test]
323+
public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsResponseToArguments()
324+
{
325+
// Arrange
326+
var action = "submit";
327+
var httpContext = new DefaultHttpContext();
328+
httpContext.Request.Headers.Add(RecaptchaServiceConstants.TokenKeyName, TokenValue);
329+
330+
_actionExecutingContext.HttpContext = httpContext;
331+
332+
_recaptchaServiceMock = new Mock<IRecaptchaService>();
333+
_recaptchaServiceMock.Setup(service => service.ValidateRecaptchaResponse(It.Is<string>(s => s == TokenValue), null))
334+
.ReturnsAsync(new ValidationResponse
335+
{
336+
Success = true,
337+
Action = action
338+
})
339+
.Verifiable();
340+
341+
_filter = new ValidateRecaptchaFilter(_recaptchaServiceMock.Object, _optionsMock.Object, _logger)
342+
{
343+
Action = action
344+
};
273345

274-
_actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = false });
346+
_actionExecutingContext.ActionArguments.Add("argumentName", new ValidationResponse { Success = false, Action = string.Empty });
275347

276348
// Act
277349
await _filter.OnActionExecutionAsync(_actionExecutingContext, _actionExecutionDelegate);
@@ -280,6 +352,7 @@ public async Task OnActionExecutionAsync_WhenValidationSuccess_ContinuesAndAddsR
280352
_recaptchaServiceMock.Verify();
281353
Assert.IsInstanceOf<OkResult>(_actionExecutingContext.Result);
282354
Assert.IsTrue((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Success);
355+
Assert.AreEqual(action, (_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Action);
283356
Assert.AreEqual((_actionExecutingContext.ActionArguments["argumentName"] as ValidationResponse).Errors.Count(), 0);
284357
}
285358

tests/ReCaptcha.Tests/ValidateRecaptchaAttributeTests.cs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,25 @@ namespace ReCaptcha.Tests
1111
[TestFixture]
1212
public class ValidateRecaptchaAttributeTests
1313
{
14+
[Test(Description = "CreateInstance(...) should throw InvalidOperationException if the library services are not registered.")]
15+
public void CreateInstance_ShouldThrowWhen_ServicesNotRegistered()
16+
{
17+
// Arrange
18+
var servicesMock = new Mock<IServiceProvider>();
19+
servicesMock.Setup(provider => provider.GetService(typeof(ValidateRecaptchaFilter)))
20+
.Returns(null);
21+
var attribute = new ValidateRecaptchaAttribute();
22+
23+
// Act
24+
25+
26+
// Assert
27+
Assert.Throws<InvalidOperationException>(() => attribute.CreateInstance(servicesMock.Object));
28+
}
29+
1430
[Test(Description = "CreateInstance(...) should return a new instance of " +
1531
"ValidateRecaptchaFilter with the default value for the OnValidationFailedAction property.")]
16-
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithDefaultAction()
32+
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithDefaultOnValidationFailedAction()
1733
{
1834
// Arrange
1935
var optionsMock = new Mock<IOptionsMonitor<RecaptchaOptions>>();
@@ -37,7 +53,7 @@ public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithDefaultActio
3753

3854
[Test(Description = "CreateInstance(...) should return a new instance of " +
3955
"ValidateRecaptchaFilter with the user set value for the OnValidationFailedAction property.")]
40-
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetAction()
56+
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetOnValidationFailedAction()
4157
{
4258
// Arrange
4359
var optionsMock = new Mock<IOptionsMonitor<RecaptchaOptions>>();
@@ -62,20 +78,31 @@ public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetActio
6278
Assert.AreEqual(ValidationFailedAction.ContinueRequest, (filterInstance as ValidateRecaptchaFilter).OnValidationFailedAction);
6379
}
6480

65-
[Test(Description = "CreateInstance(...) should throw InvalidOperationException if the library services are not registered.")]
66-
public void CreateInstance_ShouldThrowWhen_ServicesNotRegistered()
81+
[Test]
82+
public void CreateInstance_ShouldReturn_ValidateRecaptchaFilter_WithUserSetAction()
6783
{
6884
// Arrange
85+
var action = "submit";
86+
var optionsMock = new Mock<IOptionsMonitor<RecaptchaOptions>>();
87+
optionsMock.SetupGet(options => options.CurrentValue)
88+
.Returns(new RecaptchaOptions());
6989
var servicesMock = new Mock<IServiceProvider>();
7090
servicesMock.Setup(provider => provider.GetService(typeof(ValidateRecaptchaFilter)))
71-
.Returns(null);
72-
var attribute = new ValidateRecaptchaAttribute();
91+
.Returns(new ValidateRecaptchaFilter(null, optionsMock.Object, null))
92+
.Verifiable();
93+
var attribute = new ValidateRecaptchaAttribute
94+
{
95+
Action = action
96+
};
7397

7498
// Act
75-
99+
var filterInstance = attribute.CreateInstance(servicesMock.Object);
76100

77101
// Assert
78-
Assert.Throws<InvalidOperationException>(() => attribute.CreateInstance(servicesMock.Object));
102+
servicesMock.Verify();
103+
Assert.IsNotNull(filterInstance);
104+
Assert.IsInstanceOf<ValidateRecaptchaFilter>(filterInstance);
105+
Assert.AreEqual(action, (filterInstance as ValidateRecaptchaFilter).Action);
79106
}
80107
}
81108
}

0 commit comments

Comments
 (0)