This module provides a CyberSource Silent Order POST (SOP) payment gateway for Drupal Commerce on Drupal 11. Card data is posted directly from the customer's browser to CyberSource, so cardholder data never touches the Drupal server, supporting PCI DSS scope reduction. Replies are HMAC-SHA256 signed and verified.
It differs from the existing commerce_cybersource module in integration method and credential handling: commerce_cybersource integrates CyberSource Flex Microform (iframe-hosted card fields over the REST API) and stores API credentials in the payment gateway configuration, while this module implements Secure Acceptance Silent Order POST with an on-page card form and keeps the Secure Acceptance profile credentials in a file in the private filesystem (resolved per mode and currency), outside site configuration and the database.
Project link
https://www.drupal.org/project/cybersource_sop
PAReview: review bonus
[1.0.x] Entity Add Another https://www.drupal.org/project/projectapplications/issues/3560166
[1.0.x] JSON-LD Generator https://www.drupal.org/project/projectapplications/issues/3607891
[1.0.x] Site Studio Manager https://www.drupal.org/project/projectapplications/issues/3594214
[1.0.x] Webform Leet Speak Filter https://www.drupal.org/project/projectapplications/issues/3607893
[1.0.x] Stack Vitals https://www.drupal.org/project/projectapplications/issues/3590738
[1.3.x] AI Validations https://www.drupal.org/project/projectapplications/issues/3607948
[1.0.x] RAG (Retrieval-Augmented Generation) Search https://www.drupal.org/project/projectapplications/issues/3580591#commen...
[2.0.x] Vocabulary Condition https://www.drupal.org/project/projectapplications/issues/3608067#commen...
[1.0.x] Protected Pages Extra — https://www.drupal.org/project/projectapplications/issues/3611460
[2.0.x] Commerce EuPlatesc Payment Gateway — https://www.drupal.org/project/projectapplications/issues/3576328
[1.x] Commerce Worldline Gopay https://www.drupal.org/project/projectapplications/issues/3572787#commen...
[1.0.x] SDC Property Inheritance https://www.drupal.org/project/projectapplications/issues/3613219#commen...
[1.0.x] Commerce MeoWallet https://www.drupal.org/project/projectapplications/issues/3615844#commen...
Comments
Comment #2
vishal.kadamComment #3
avpadernoComment #4
avpadernoThank you for applying!
Before giving links helpful to understand how the review process works, what to expect from a review, and what to do to avoid a review takes more time than needed, I would like to thank all the reviewers for the work they do.
These applications are volunters-driven, which also means it is not possible to predict when an application will be marked fixed and the applicant will get the permission to opt projects into security advisory policy. While we aim to make an application as quick as possible, it is also important for us that more people review the project used for an application. In this way, we make sure applications do not miss some important points that should be instead reported.
Applications are not meant to be complete debugging sessions that eliminate every existing bug, though. I apologize if sometimes applications seem to go into too-detailed reviews.
Please read Review process for security advisory coverage: What to expect for more details and Security advisory coverage application checklist to understand what reviewers look for. Tips for ensuring a smooth review gives some hints for a smoother review.
The important notes are the following.
Keep in mind that once the project is opted into security advisory coverage, only Security Team members may change coverage.
To the reviewers
Please read How to review security advisory coverage applications, Application workflow, What to cover in an application review, and Tools to use for reviews.
The important notes are the following.
For new reviewers, I would also suggest to first read In which way the issue queue for coverage applications is different from other project queues.
Comment #5
avpadernoComment #6
longwaveIt appears that you may have used AI tools in this application, module code, and even for the review bonus comments.
If that is correct, please read Drupal's AI contribution policy carefully and ensure that you have followed it in all cases: https://www.drupal.org/docs/develop/issues/issue-procedures-and-etiquett...
Comment #7
avpadernoAs a side note, to use the review bonus, it is necessary to use the PAreview: review bonus issue tag.
Using the review bonus is probably not necessary, since this is a recent application and reviewers will probably review first the last created applications.
Comment #8
brian.willows@hsj.co.uk commentedI have used AI assistance, but I'm an experienced Drupal programmer and reviewed what was produced. Also, it is based on code I wrote from scratch so I believe I comply with Drupal policies
Comment #9
brian.willows@hsj.co.uk commentedhttps://www.drupal.org/project/projectapplications/issues/3594107#commen...
Comment #10
brian.willows@hsj.co.uk commentedhttps://www.drupal.org/project/projectapplications/issues/3594214#commen...
Comment #11
brian.willows@hsj.co.uk commentedhttps://www.drupal.org/project/projectapplications/issues/3590738#commen...
https://www.drupal.org/project/projectapplications/issues/3607893#commen...
https://www.drupal.org/project/projectapplications/issues/3607891#commen...
https://www.drupal.org/project/projectapplications/issues/3607948#commen...
https://www.drupal.org/project/projectapplications/issues/3560166#commen...
Comment #12
brian.willows@hsj.co.uk commentedhttps://www.drupal.org/project/projectapplications/issues/3608067#commen...
Comment #13
brian.willows@hsj.co.uk commentedhttps://www.drupal.org/project/projectapplications/issues/3608139#commen...
Comment #14
brian.willows@hsj.co.uk commentedhttps://www.drupal.org/project/projectapplications/issues/3593111#commen...
Comment #15
vishal.kadamFILE: cybersource_sop.module
A new module that aims to be compatible with latest Drupal releases is expected to implement hooks as class methods as described in Support for object oriented hook implementations using autowired services.
Comment #16
brian.willows@hsj.co.uk commentedConverted in af7d882 on 1.0.x: hook_help is now a #[Hook] class method in src/Hook/CybersourceSopHooks.php per the OO hooks change record, and cybersource_sop.module is reduced to a #[LegacyHook] stub delegating through the class resolver, since the module also supports Drupal 10.3 where the attribute classes do not exist (on 11.1+ the stub is skipped, so each hook runs exactly once). The module file's procedural helper functions moved into an injectable CredentialsStatus service, used by both hook_requirements() and the admin-page event subscriber. Verified on both majors: the full test suite (38 tests, 525 assertions), PHPCS and PHPStan level 6 pass on Drupal 11.3 and on a Drupal 10.6 install. I have also removed a leftover editorial note from the issue summary and corrected the comparison with commerce_cybersource.
I use Assisted AI but I review what it generates
Comment #17
avpadernoAs a side note, in a comment that changes the status to Needs review, it is not necessary to describe what it has been done. Reviewers assume that the project has been changed following the review, if a comment does not state I did not do this because [...].
Comment #18
brian.willows@hsj.co.uk commentedComment #19
matt-whelan commentedI reviewed this on a fresh Drupal 11.4 with Commerce 3 install and looked at the security-critical paths properly rather than just reading them.
Suite's green here (38 tests, 525 assertions), phpcs clean against your own ruleset, phpstan level 6.
I dropped in a test credentials file with a known secret and fired forged and tampered replies at the return route to check the project-page claims hold. They do. An unsigned reply, a forged signature or one that leaves a critical field out of
signed_field_namesall get a 403 and record nothing. A signed reply whose amount doesn't match the order is refused, and the recorded amount comes from the order rather than the POST. One signed for a different order and POSTed here is refused on the reference check. A signed decline lets the customer retry with nothing recorded. A signed ACCEPT records a single authorisation, and replaying it leaves one payment - every attempt logged, no card data. The card fields are id-only and the JS has no fetch/XHR/storage, so card data really does go straight to Cybersource. I also tried the outbound side and the state machine - tampering the signed set, dropping signed fields, replay, a double-submit race - and it held, since the signing partition is itself signed and the order total is authoritative.A few things, none a blocker:
authenticateReply()callslogResponse()on the signature-invalid branch, and the route's open (correctly - it's HMAC-authenticated, not session-authenticated). So an unauthenticated POST for an order on this gateway still writes acommerce_logrow before it's rejected; three junk POSTs here were all 403 with no payment but each still added a row. Minor, and it falls out of logging every attempt by design, but you might want the write behind the signature check, or the route flooded.cybersource_sop_requirements()has no#[LegacyRequirementsHook]attribute, so it trips on 11.3+ (I saw it in your own kernel run) - adding that, same BC shape as yourhook_helpconversion, sorts it and stays 10.3-safe since the attribute's only read via reflection. And the gateway still uses the@CommercePaymentGatewayannotation rather than the attribute, a straight swap since Commerce 3 has it.number_format(..., 2), fixed at two decimals. Nothing stops a store using a currency that isn't two-decimal (JPY none, KWD three), and the signed amount is then the wrong precision. Not a pricing risk, since the recorded payment always uses the order total, but it'd break checkout for three-decimal currencies where the return-side check rejects the truncated value. Deriving the decimals from the currency covers it.onReturn()directly. They work (I checked live), so it's regression protection rather than a defect - a small request-level or functional test would lock it in.No blockers from me. I'd recommend it for coverage.
Comment #20
brian.willows@hsj.co.uk commentedAll four points are addressed on 1.0.x: (1) commit 5ebdfc9 — the invalid-signature branch now logs to the module's channel instead of writing a commerce_log entity, so an unauthenticated POST writes nothing; (2) commits 812ac37 and 534543e — the gateway uses the CommercePaymentGateway attribute, and the runtime requirements moved to hook_runtime_requirements() with a #[LegacyRequirementsHook] stub so Drupal 10.3–11.2 report the same entries exactly once; (3) commit 5258ce9 — the signed amount now uses the currency's fraction digits rather than a fixed 2; (4) commit cafe3cb — request-level kernel tests cover the 405, the 403 on an unauthenticated reply, the cross-order reference check and the cancel route, asserting the denials write no entities.
I use Assisted AI but I review what it generates
Comment #21
matt-whelan commentedThanks Brian. Had a good follow up look - all four fixes are now present on 1.0.x at
cafe3cb, re-verified on the same install as my original review. The denial branches now don't write anything - I re-fired the unsigned, forged and junk POSTs and thecommerce_logno longer logs, while the signed flows still log as they should. The requirements deprecation is gone from the test run (the ones still showing belong to address, commerce and commerce_price) and the gateway discovers fine under the attribute. The new request-level tests take the suite to 43 tests / 690 assertions with every denial path asserting no payments and no log entities, which closes the controller gap. phpcs against your ruleset and phpstan level 6 both come back clean, and the full set of adversarial probes from my first pass still behaves at the new HEAD.The fraction-digits change reads correctly at both call sites (the signing amount and the display string), though it's the one fix without a test of its own - a JPY or KWD case in the new kernel class would be good if you're in there again - but not a blocker by any means.
That's everything I raised, so marking it RTBC.
Comment #22
cmlara$this->fileSystem->realpath(self::CREDENTIALS_URI);How do you justify this in light of
FileSytemInterface::realpath()usage instructionsOnly use this function if you know that the stream wrapper in the URI uses the local file system?What was the security analysis on how a configurable path is less exploitable than a well known non-configurable path in private:// ? What factors did you include in your analysis?
Setting needs-work for answer to the above questions related to proper use of the Drupal API and secure code development.
Comment #23
avpadernoI wonder what a configurable path would let an admin point the gateway at attacker-controlled credentials is supposed to mean or suggest.
Comment #24
brian.willows@hsj.co.uk commentedThank you both - fair challenges, and taking them in turn.
realpath() on private:// - you're right, and it was a real defect, not just a documentation violation: with the private filesystem on a remote stream wrapper (s3fs, flysystem) realpath() returns FALSE and the gateway cannot load its credentials at all. My own kernel tests even carried a workaround ("vfsStream does not support realpath, so back private:// with a real directory"), which should have been the clue.
Fixed in https://git.drupalcode.org/project/cybersource_sop/-/commit/6992db77e8ec...
CredentialProvider now does all access through the stream wrapper (file_exists / is_readable / filemtime / file_get_contents on the URI), CredentialsStatus checks isValidScheme('private') instead, and the world-readable-permissions warning is explicitly conditional on the wrapper being a LocalStream - file permissions are only meaningful there, and remote backends' access control belongs to the remote service. The kernel tests now run against the vfsStream-backed private:// (a wrapper realpath() cannot resolve), so the remote case is exercised on every run, and a unit test drives CredentialProvider through a deliberately non-local stream wrapper.
"What was the security analysis?" - candidly, the comment you quoted overstated the case and I have removed it. I did not mean a configurable path is exploitable in a way a fixed one is not - an attacker who can already change gateway configuration has better options, as #23 implies. The actual reasoning for the fixed location, which the code now states: (1) secrets never enter config entities, so they cannot leak through config export, git, config sync between environments, or a copied staging database - this was the primary driver; (2) no admin form reads or writes credential material, so reading or replacing the signing secret always requires filesystem/deployment access rather than a Drupal role; (3) a single well-known URI keeps the requirements check, admin messages and documentation unambiguous. I make no claim the fixed path is harder to exploit - arbitrary file read defeats either, as it would defeat credentials in the database.
I use Assisted AI but I review what it generates
Comment #25
cmlaraI have concerns with that in the answer of comment 24.
I asked what the individual person used to evaluate, and the response to me appears typical of an LLM that has been caught with incorrect data scrambling to correct itself.
If that is the case the responses tells us little about the individuals knowledge and only informs us of the LLM's generated data. Prompt logs might be revealing if they indicate what the developer feed into the AI to direct the security design of those portions.
I chose the two points points very intentional.
The
realpath()as while its a common error it is, to me(as a developer who works in the fringes impacted byrealpath()), indicative of how well a coder is evaluating the LLM code as written against the Drupal Core API in full (and not just some method signature or copied from other modules).The static file path as it touches on design choices and how they interact not just in a localized scope (inside the module) by including a global scope (considering a full Drupal install including all ancillary components). I am seeing some evaluation of the local scope though not sure I'm seeing a global scope understanding of the impact.
Comment #26
brian.willows@hsj.co.uk commentedThe design was all mine. I was the author behind https://store.wilmingtonplc.com/pay-now/ which uses Cybersource and all the assets I transferred to Drupal. I have a record of code review eg SA-CONTRIB-2026-041. I fully own up that I didn't have your knowledge of realpath(). I'm not a vibe coder. I've more than 50 years of programming experience and now use AI to become more productive.
I designed (from the beginning) that the configuration of keys should not be able to be modified by someone who has admin access to the website.
Before AI I wrote a custom module called update_jira which uses Atlassian API to create Jira tickets from the updates needed for Drupal. I've enhanced that since AI so that Claude can read Jira tickets and plan the work. I review the plan and the code generated and do all the developer testing.
The danger from AI is Dunning Kruger. People feel more powerful than they are and don't have the background to review or know the mistakes any programmer makes. Making mistakes is part of programming.
I was frustrated by the Cybersource payment module and had to move to stripe because of the lack of the module I've now created. I hope to become a valuable member of the Drupal community having stayed out of it since I started with Drupal 7.
I hope that helps.
Brian (not Claude)
Comment #27
avpadernoAs @cmlara said, these applications aim to understand what the applicants understand about writing Drupal code which correctly uses the Drupal API, follows the Drupal coding standards, and does not contain possible security issues. Indirectly, we also check what they understand when asked to make changes, in preparation for security issues created for projects they maintain.
That is not possible, when changes to the project files, or responses to review comments, are done using AI tools.
Since you said I designed (from the beginning) that the configuration of keys should not be able to be modified by someone who has admin access to the website. may you list all the possibilities you considered as alternatives to using a static file path and the reason for which you discarded each solution?
Comment #28
brian.willows@hsj.co.uk commentedI considered using admin configuration: Gaining admin access is a realistic possibility and being able to see the secret key can sign transaction orders, so I rejected this. Being able to update the keys gives the ability to take the funds to a different account (and generally you would not remember a particular key as being yours without checking)
I didn't consider the key module but would consider looking at it if there was a case and a desire to use it.
Then it was really between environment variables and private storage. I've got a lot of hosting experience and know that many webhosts do not give you the ability to set environment variables, so although this might have been stronger, it would not be universally available.
Using a .yml file in the private:// area is rotatable by sysadmin using ssh or sftp (depending on the host) and there's generally fewer sysadmins with this ability than drupal site admins making it the stronger choice.
Comment #29
avpadernoGaining access to an account with the right permissions would allow to change the values entered in a settings form. I would not use a form element that allows to directly enter the private key value, but rather a form element that allows to upload the file containing the private key; the person who gained access to the account would not be able to read the current private key value but it would still be possible to change the private key.
Effectively, a setting form would not be the right approach in this case.
Files uploaded/stored under private:// are still downloadable. Drupal core invokes a specific hook and, basing on what returned by the implementations of that hook, allows the file to be downloaded. In this case, using a hard-coded filename does not help.
I would rather do as Drupal core does for settings that cannot be changed using a setting form or implement the hook Drupal core uses to allow downloading a file which is not stored under public://.
In the first case, the filename would not be hardcoded; in the second case, knowing the filename would not help, except in the case the attacker finds a way to bypass the Drupal core protection for private:// files.
Comment #30
cmlaraTo expand on the previous posts, here is a list of weaknesses that I see of storing a key under private:// that come to mind on a cursory evaluation (more likely exist if one does a full risk analysis) (some of this might be a duplicate of previously made points):
Most of these do not appear by the statements to have been considered. Some of these require an additional weakness to be present while some are part of a normal Drupal site operation.
Note: If its an asymmetric key with Cybersource having the only private half the read threats may be somewhat minor.
Its unclear from the updated post if the applicant considered non UI methods of configuring the path (that would still require disk access or RCE of higher access than a simple private:// rewrite). The settings.php and Symonfy/Drupal Container both exist, the settings.php being a fundamental basic of Drupal development and site operation. It does appear that the developer excluded all raw disk as form of 'private storage'.
Comment #31
brian.willows@hsj.co.uk commentedRe: @cmlara's comment #30: I fully concede that I did not think of all the issues you've raised (thanks for taking the time). As a result, I've changed how the keys are stored (ref commit 5239aab).
Credentials now come from settings.php in one of two ways:
a) An absolute path to the keys file (where it's possible to put outside the document root). The actual path is chosen by the developer so there is no well-known location to attack.
b) The credentials can be set within settings.php using code and whatever mechanism the developer is happiest with.
Were container parameters considered? They were rejected because parameters are compiled and serialized into the cached container.
What about file upload of the keys? Rejected because it allows replacement of the keys by anyone with admin permissions.
Comment #32
hsjbrianwillows commentedComment #33
hsjbrianwillows commentedComment #34
hsjbrianwillows commentedComment #35
hsjbrianwillows commentedComment #36
vishal.kadamI am changing priority as per Issue priorities.
Comment #37
avpadernoComment #38
avpadernosrc/Controller/CybersourceReturnController.php
Since that class does not use methods from the parent class, or it uses a single method from the parent class, it does not need to use
ControllerBaseas parent class.Controllers do not need to have a parent class; as long as they implement
\Drupal\Core\DependencyInjection\ContainerInjectionInterface, they are fine.With Drupal 10.2.x and higher versions, a controller class does not need to implement
create(); usingAutowireTrait, it just needs to implement the class constructor.Using a class property for a logger channel object is considered an anti-pattern. The class should have a property for the logger factory.
The placeholders used in translatable messages are supposed to help translator for what the placeholder exactly is. It is better to use a longer placeholder name, which would give to the translators more information about the context in which the string is used.
src/EventSubscriber/CredentialsCheckSubscriber.php
Since the class is
final, and cannot be extended, its properties can beprivateinstead ofprotected.The current_user service implements
\Drupal\Core\Session\AccountProxyInterface, which extends\Drupal\Core\Session\AccountInterface.src/Hook/CybersourceSopHooks.php
The long description in that documentation comment is not necessary. It is just a superfluous part added by AI.
OOP classes do not need to implement that method, nor to implement
ContainerInjectionInterface. They are service Drupal 11 automatically handle and like normal services, they do not need to implementcreate().I did not verify what previously reported in a review and whenever that was completely fixed.
Comment #39
avpadernoAs a side note, this application was created using the brian.willows@hsj.co.uk account. That is the account which will get the permission to opt projects into security advisory coverage.
Comment #40
brian.willows@hsj.co.uk commentedI've followed your advice, registering the hook class in the yml file, but dropping the create() breaks Drupal 10.3 so I've kept that. Hope that's OK now
Comment #41
avpadernoSince the hook class is set as service class using the following lines, there should no need to implement
create(), like with any other service class.Comment #42
avpadernoThank you for your contribution and for your patience with the review process!
I am going to update your account so you can opt into security advisory coverage any project you create, including the projects you already created.
These are some recommended readings to help you with maintainership:
You can find more contributors chatting on Slack or IRC in #drupal-contribute. So, come hang out and stay involved!
Anyone is welcome to participate in the review process. Please consider reviewing other projects that are pending review. I encourage you to learn more about that process and join the group of reviewers.
I thank also all the reviewers for helping with these applications.
Comment #43
avpaderno