Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.

## [2.0.9]

### Added
- Add signature verification to enhance the callbacks security.

## [2.0.8]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ var Logger = require('dw/system/Logger').getLogger('MarketPay', 'MarketPay');
var COHelpers = require('*/cartridge/scripts/helpers/marketPayCheckoutHelpers');
var ipHelpers = require('*/cartridge/scripts/helpers/ipHelpers');
var notificationHelpers = require('*/cartridge/scripts/helpers/marketPayNotificationHelpers');
var signatureHelpers = require('*/cartridge/scripts/helpers/marketPaySignatureHelpers.js');

server.post('CallbackForm', server.middleware.https, function (req, res, next) {
var languageCode = req.form.language;
Expand All @@ -34,6 +35,9 @@ server.post('PaymentSuccess', server.middleware.https, function (req, res, next)
var order = null;

try {
if (!signatureHelpers.validateRequest(req)) {
throw new Error("Invalid request.");
}
orderID = req.form.shop_orderid;
var orderXMLObject = new XML(req.form.xml);
var transactions = orderXMLObject.Body.Transactions.Transaction;
Expand Down Expand Up @@ -81,6 +85,9 @@ server.post('PaymentFail', server.middleware.https, function (req, res, next) {
var order = null;

try {
if (!signatureHelpers.validateRequest(req)) {
throw new Error("Invalid request.");
}
var orderXMLObject = new XML(req.form.xml);
var transactions = orderXMLObject.Body.Transactions.Transaction;
var latestTxn = marketPayDataHelper.getLatestTransaction(transactions);
Expand Down Expand Up @@ -115,6 +122,12 @@ server.post('PaymentFail', server.middleware.https, function (req, res, next) {
* This controller is for asynchronous payments, when the acquirer returns an answer for payment request.
*/
server.post('PaymentNotification', server.middleware.https, function (req, res, next) {
if (!signatureHelpers.validateRequest(req)) {
res.setStatusCode(400);
res.json({ message: 'Invalid request.' });

return next();
}
// Ignore new status
if (req.form.status === 'new') {
res.setStatusCode(200);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ function getSessionDataModel() {
transactionInfo: {
ecomPlatform: "Salesforce",
ecomPluginName: "int_marketpay_headless",
ecomPluginVersion: "2.0.8"
ecomPluginVersion: "2.0.9"
}
},
callbacks: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use strict';

var Mac = require('dw/crypto/Mac');
var Encoding = require('dw/crypto/Encoding');
var Bytes = require('dw/util/Bytes');
var Site = require('dw/system/Site');

// Matches Java URLEncoder: space→+, encode all chars except A-Za-z0-9 - _ . *
// encodeURIComponent leaves ( ) ! ~ ' unencoded but Java URLEncoder encodes them.
function formEncode(str) {
return encodeURIComponent(str)
.replace(/%20/g, '+')
.replace(/[!()'~]/g, function (c) {
return '%' + c.charCodeAt(0).toString(16).toUpperCase();
});
}

function validateRequest(req) {
var secret = Site.getCurrent().getCustomPreferenceValue('marketPayCallbackSecret');

if (!secret) {
return true; // No secret configured, skip validation
}

// Step 1: Parse the AltaPay-Signature header
var signatureHeader = req.httpHeaders.get('altapay-signature');

if (!signatureHeader) {
return false;
}

var timestamp = null;
var signatures = [];

signatureHeader.split(';').forEach(function (field) {
var trimmed = field.trim();
if (trimmed.indexOf('t=') === 0) {
timestamp = trimmed.substring(2);
} else if (/^s\d+=/.test(trimmed)) {
signatures.push(trimmed.split('=')[1]);
}
});

if (!timestamp || signatures.length === 0) {
return false;
}

// Step 2: Prepare the payload — rawBody + "." + timestamp
// SFCC parses application/x-www-form-urlencoded bodies into httpParameterMap on
// request arrival, so requestBodyAsString is always null for this content type.
// Reconstruct by re-encoding parameters in received order (Tomcat preserves it).
var paramMap = request.httpParameterMap;
var parts = [];
var paramNamesIter = paramMap.getParameterNames().iterator();
while (paramNamesIter.hasNext()) {
var paramName = paramNamesIter.next();
var stringValues = paramMap.get(paramName).getStringValues();
if (stringValues.isEmpty()) {
// SFCC returns empty Collection for params submitted with no value (e.g. "error_message=")
parts.push(formEncode(paramName) + '=');
} else {
var valuesIter = stringValues.iterator();
while (valuesIter.hasNext()) {
var paramValue = valuesIter.next();
// SFCC strips trailing \n from parameter values; AltaPay appends \n after </APIResponse>
if (paramName === 'xml') {
paramValue += '\n';
}
parts.push(formEncode(paramName) + '=' + formEncode(paramValue));
}
}
}
var rawBody = parts.join('&');

var mac = new Mac(Mac.HMAC_SHA_256);
var payload = rawBody + '.' + timestamp;
var calculatedHex = Encoding.toHex(mac.digest(new Bytes(payload, 'UTF-8'), new Bytes(secret, 'UTF-8')));
var signatureValid = signatures.some(function (sig) { return calculatedHex === sig; });

if (!signatureValid) {
return false;
}

return true;
}

module.exports = {
validateRequest: validateRequest
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ const SCAPIService = require('*/cartridge/scripts/services/scapiService');
exports.modifyGETResponse_v2 = function (basket, paymentMethodResultResponse) {
const marketPayDataHelper = require('*/cartridge/scripts/helpers/marketPayDataHelper');

// MarketPay specific payment method IDs to validate
var marketPayMethods = [
'MARKETPAY_CREDITCARD',
'MARKETPAY_MOBILEPAY',
'MARKETPAY_VIPPS',
'MARKETPAY_KLARNA',
'MARKETPAY_IDEAL',
'MARKETPAY_VIABILL',
'MARKETPAY_SWISH',
'MARKETPAY_BANCONTACT',
'MARKETPAY_BANKPAYMENT',
'MARKETPAY_TWINT',
'MARKETPAY_TRUSTLY',
'MARKETPAY_PRZELEWY24',
'MARKETPAY_PAYPAL',
];

try {
var result = SCAPIService.createMarketPaySession(basket.customer.ID, marketPayDataHelper.getFormattedDataForMarketPaySession(basket));

Expand All @@ -29,23 +46,6 @@ exports.modifyGETResponse_v2 = function (basket, paymentMethodResultResponse) {
marketPayTerminalsMapping = JSON.parse(marketPayTerminalsMapping);
}

// MarketPay specific payment method IDs to validate
var marketPayMethods = [
'MARKETPAY_CREDITCARD',
'MARKETPAY_MOBILEPAY',
'MARKETPAY_VIPPS',
'MARKETPAY_KLARNA',
'MARKETPAY_IDEAL',
'MARKETPAY_VIABILL',
'MARKETPAY_SWISH',
'MARKETPAY_BANCONTACT',
'MARKETPAY_BANKPAYMENT',
'MARKETPAY_TWINT',
'MARKETPAY_TRUSTLY',
'MARKETPAY_PRZELEWY24',
'MARKETPAY_PAYPAL',
];

// Check if marketPayTerminalsMapping is valid
if (!marketPayTerminalsMapping || !marketPayTerminalsMapping.terminals) {
Logger.warn("MarketPay terminals mapping not found or invalid");
Expand Down Expand Up @@ -84,8 +84,13 @@ exports.modifyGETResponse_v2 = function (basket, paymentMethodResultResponse) {
Logger.error("MarketPay error in modifyGETResponse_v2: " + e.message);
Logger.error("Stack trace: " + e.stack);

// Return original payment methods on error
return;
// Filter out MarketPay methods on error.
var allMethods = paymentMethodResultResponse.applicablePaymentMethods;
if (allMethods) {
paymentMethodResultResponse.applicablePaymentMethods = allMethods.toArray().filter(function (method) {
return marketPayMethods.indexOf(method.id) === -1;
});
}
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@
</type-extension>
<type-extension type-id="SitePreferences">
<custom-attribute-definitions>
<attribute-definition attribute-id="marketPayCallbackSecret">
<display-name xml:lang="x-default">Callback Secret</display-name>
<description xml:lang="x-default"></description>
<type>string</type>
<mandatory-flag>false</mandatory-flag>
<externally-managed-flag>false</externally-managed-flag>
<min-length>0</min-length>
</attribute-definition>
<attribute-definition attribute-id="marketPayAppURL">
<display-name xml:lang="x-default">App URL</display-name>
<description xml:lang="x-default">This is an app URL where the customer will be
Expand Down Expand Up @@ -189,6 +197,7 @@
<attribute attribute-id="marketPayOrganizationID" />
<attribute attribute-id="marketPayOrganizationShortCode" />
<attribute attribute-id="marketPayKnownIPProtection" />
<attribute attribute-id="marketPayCallbackSecret" />
</attribute-group>
</group-definitions>
</type-extension>
Expand Down