Magento contactUs GraphQL Mutation Isn't Protected by reCAPTCHA
A store I work on started receiving a flood of contact enquiry emails - a couple of hundred in the space of a few minutes, all from a single host. The contact form has reCAPTCHA enabled and has done for over a year, so on the face of it this shouldn’t have been possible. The reCAPTCHA config was correct, the keys were in place, and the form was rejecting bots exactly as it should.
The emails were still getting through. Here’s what was actually happening, and why the contact form was never the way in.
reCAPTCHA on the contact form only covers the form
When you enable reCAPTCHA for “Contact Us” in Magento, the protection comes from the Magento_ReCaptchaContact module. If you look at what that module actually ships, it wires up a single observer:
<event name="controller_action_predispatch_contact_index_post">
<observer name="recaptcha_on_contact_form"
instance="Magento\ReCaptchaContact\Observer\ContactFormObserver"/>
</event>That observer runs on controller_action_predispatch_contact_index_post - the controller action behind the standard POST /contacts/index/post/ form submission. No token, and the request is redirected back to the form without sending anything. This part works. I confirmed it by posting to the form endpoint with a valid form key and no reCAPTCHA response, and the submission was correctly rejected.
The problem is that the contact form controller isn’t the only way to send that email.
The contactUs GraphQL mutation reaches the same mailer
Magento ships a contactUs GraphQL mutation as part of Magento_ContactGraphQl, and it’s enabled by default. Its resolver does this:
$this->mail->send($input['email'], ['data' => $input]);That $this->mail is Magento\Contact\Model\MailInterface - the exact same mailer the form controller uses, sending the exact same configured contact email template. But the resolver has no reCAPTCHA check. GraphQL is also exempt from form key and CSRF validation, so there’s nothing to supply and nothing to forge.
A single request is all it takes:
curl -s -X POST "https://www.example.com/graphql" \
-H "Content-Type: application/json" \
--data '{"query":"mutation { contactUs(input: { name: \"Test\", email: \"test@example.com\", telephone: \"0000\", comment: \"Test\" }) { status } }"}'A successful call returns {"data":{"contactUs":{"status":true}}} and fires the enquiry email. Loop that a couple of hundred times and you have the flood. Because GraphQL requests with no Store header resolve to the default store view, every email arrived branded as the default site regardless of which store the attacker referenced.
This is also why it’s so easy to miss when you go looking in the logs. The form POSTs show up clearly in the access log as POST /contacts/index/post/ - and they were all being rejected. The requests that actually sent mail were POST /graphql, and nginx doesn’t log GraphQL request bodies, so there’s nothing in the access log that says contactUs at all.
Confirming reCAPTCHA really was working
Before backporting anything, it’s worth proving the form-level reCAPTCHA wasn’t just broken. The same host that ran the enquiry flood also hammered customer/account/createpost, which is protected by reCAPTCHA in exactly the same way. If reCAPTCHA had been failing open, those would have created accounts. Checking the database for the window in question, zero accounts were created. The gate was up and doing its job - the GraphQL mutation simply walked around it.
The fix landed in Magento 2.4.9
This is a genuine gap in core, and Adobe fixed it in Magento 2.4.9 (commit LYNX-941, “Implement ReCaptcha for missing GraphQl mutations”, shipped in magento/security-package 1.1.8). The ContactUs resolver itself is unchanged - the fix is on the reCAPTCHA side.
Server-side reCAPTCHA for web API and GraphQL endpoints is driven by CompositeWebapiValidationConfigProvider. Each reCAPTCHA module registers its endpoints into that provider via etc/di.xml. If you look at 2.4.8, the customer, checkout, newsletter, review, send-a-friend and PayPal modules all register - but Magento_ReCaptchaContact ships no etc/di.xml at all, so contact is simply absent from the list. The 2.4.9 fix adds the missing registration and a small provider class that returns the existing contact validation config for the ContactUs endpoint.
Backporting the fix to 2.4.8
If you can’t jump to 2.4.9 immediately, I’ve packaged the backport as a small module and released it for free under the MIT licence: DeployEcommerce/module-contact-graphql-recaptcha. Drop it in, enable it, and the contactUs mutation is gated by your existing contact reCAPTCHA config.
If you’d rather understand what it’s doing or build your own, the fix backports cleanly because all the infrastructure it depends on already exists in 2.4.8. You need two files.
A provider that returns the contact validation config when it sees the ContactUs endpoint:
<?php
declare(strict_types=1);
namespace Vendor\ContactGraphQlReCaptcha\Model;
use Magento\ContactGraphQl\Model\Resolver\ContactUs;
use Magento\ReCaptchaUi\Model\IsCaptchaEnabledInterface;
use Magento\ReCaptchaUi\Model\ValidationConfigResolverInterface;
use Magento\ReCaptchaValidationApi\Api\Data\ValidationConfigInterface;
use Magento\ReCaptchaWebapiApi\Api\Data\EndpointInterface;
use Magento\ReCaptchaWebapiApi\Api\WebapiValidationConfigProviderInterface;
class WebapiConfigProvider implements WebapiValidationConfigProviderInterface
{
private const CAPTCHA_ID = 'contact';
public function __construct(
private readonly IsCaptchaEnabledInterface $isEnabled,
private readonly ValidationConfigResolverInterface $configResolver
) {
}
public function getConfigFor(EndpointInterface $endpoint): ?ValidationConfigInterface
{
if ($endpoint->getServiceMethod() === 'resolve'
&& $endpoint->getServiceClass() === ContactUs::class
&& $this->isEnabled->isCaptchaEnabledFor(self::CAPTCHA_ID)
) {
return $this->configResolver->get(self::CAPTCHA_ID);
}
return null;
}
}And an etc/di.xml that registers it into the composite provider:
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\ReCaptchaWebapiApi\Model\CompositeWebapiValidationConfigProvider">
<arguments>
<argument name="providers" xsi:type="array">
<item name="vendor_recaptcha_on_contact_form" xsi:type="object">Vendor\ContactGraphQlReCaptcha\Model\WebapiConfigProvider</item>
</argument>
</arguments>
</type>
</config>Add the usual registration.php and etc/module.xml (sequence it after Magento_ReCaptchaContact, Magento_ReCaptchaWebapiApi and Magento_ContactGraphQl), and enable it. I deliberately kept this under my own vendor namespace rather than overriding Magento\ReCaptchaContact, so it doesn’t collide with core when the platform is eventually upgraded to 2.4.9 - at which point the module can just be removed.
Because it reuses the contact validation config, it’s gated by the same reCAPTCHA settings you’ve already configured. Legitimate token-bearing submissions are unaffected - only the tokenless GraphQL calls get blocked.
Watch out for the DI cache
One thing that caught me out while testing: the providers argument on the composite provider is merged from di.xml and cached. Adding the module isn’t enough on its own - you have to compile DI and flush the cache, otherwise the merged config still reflects the old, ungated state and the mutation stays wide open with no obvious sign anything is wrong:
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flushAfter that, re-run the contactUs curl from earlier. Instead of {"status":true} you’ll get a reCAPTCHA validation error, and no email is sent.
Worth checking on your own stores
If you’re on 2.4.8 or earlier with Magento_ContactGraphQl enabled and you don’t have a headless or PWA storefront that actually needs it, the simplest fix of all is to disable the module:
bin/magento module:disable Magento_ContactGraphQlThat closes the hole entirely with no custom code. But if you’re using GraphQL, install the extension - and either way, it’s worth rate-limiting /graphql at the edge while you’re at it. The contact form is the obvious thing to protect, but it’s rarely the only door into the same piece of functionality.