Clickjacking in Sails with Hmac Signatures
Clickjacking in Sails with Hmac Signatures — how this specific combination creates or exposes the vulnerability
Clickjacking is a client-side attack that tricks a user into interacting with a hidden or disguised element inside an embedded page. When Sails.js applications embed responses inside frames or iframes and rely solely on HMAC signatures for integrity without additional anti-clickjacking controls, the signature can indirectly support an exploitable flow. A typical scenario: an endpoint protected by HMAC signatures issues a JSON payload or form intended for use inside a specific origin. If that response is embeddable in an attacker-controlled page via an iframe, the user may be made to perform unintended actions (e.g., clicking a button that triggers state-changing requests). The HMAC signature on the payload or request ensures data integrity between the server and the client, but it does not prevent the resource from being embedded in a malicious context. Therefore, the combination creates a situation where integrity is preserved but authorization and UI confinement are not enforced, enabling clickjacking via embedded content that appears legitimate. Without frame restriction headers such as Content-Security-Policy frame-ancestors or X-Frame-Options, browsers will render the embedded resource, and the HMAC-verified UI elements can be overlaid with invisible controls to hijack user input.
Hmac Signatures-Specific Remediation in Sails — concrete code fixes
Remediation focuses on preventing embedding and ensuring that HMAC-signed flows are not usable in an unintended context. You should apply frame-protection headers and ensure HMAC verification is tied to context-bound tokens or one-time nonces to reduce risk. Below are concrete Sails.js examples.
Set frame-protection headers in Sails policies
// api/policies/csp-frame-policy.js
module.exports.cspFramePolicy = function (req, res, next) {
// Prevent any site from framing this response
res.set('Content-Security-Policy', "frame-ancestors 'none'");
// For broader browser compatibility you can also send X-Frame-Options
res.set('X-Frame-Options', 'DENY');
return next();
};
Register the policy in config/policies.js to apply it to relevant controllers or actions:
// config/policies.js
module.exports.policies = {
'*': ['cspFramePolicy'],
'SensitiveController': {
'*': ['cspFramePolicy', 'requireHmac']
}
};
HMAC-signed request with nonce and origin binding in Sails
When issuing HMAC-signed tokens or responses, bind them to the request origin and a nonce to prevent reuse in embedded contexts. The following example shows how to generate and verify HMAC-signed payloads that include origin and nonce.
Generating a signed response (server-side):
// api/helpers/hmac.js
const crypto = require('crypto');
function generateSignedPayload(user, origin, nonce, secret) {
const data = {
sub: user.id,
origin: origin,
nonce: nonce,
exp: Math.floor(Date.now() / 1000) + 300 // 5 minutes
};
const payload = Buffer.from(JSON.stringify(data)).toString('base64url');
const signature = crypto.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return { payload, signature };
}
module.exports.generateSignedPayload = generateSignedPayload;
Using the signed payload in a Sails controller action:
// api/controllers/ActionController.js
const { generateSignedPayload } = require('../helpers/hmac');
module.exports.performAction = async function (req, res) {
const origin = req.headers.origin || req.get('referer') || '';
const nonce = req.session?.hmacNonce || cryptoRandomString({ length: 16, type: 'url' });
const secret = sails.config.custom.hmacSecret;
const { payload, signature } = generateSignedPayload(req.session.user, origin, nonce, secret);
// Example: returning a form with HMAC-signed hidden fields for safe consumption
return res.view('action', {
payload,
signature,
frameProtection: true
});
};
Verifying the signed payload (server-side):
// api/helpers/verifyHmac.js
const crypto = require('crypto');
function verifySignedPayload(payload, receivedSignature, secret, expectedOrigin) {
const computedSignature = crypto.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(computedSignature), Buffer.from(receivedSignature))) {
throw new Error('Invalid signature');
}
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
if (decoded.origin !== expectedOrigin) {
throw new Error('Origin mismatch — possible embedding attempt');
}
if (Math.floor(Date.now() / 1000) > decoded.exp) {
throw new Error('Token expired');
}
return decoded;
}
module.exports.verifySignedPayload = verifySignedPayload;
In the receiving endpoint, validate the origin against the one stored in the session or a strict allow-list, and reject requests that do not match. This prevents an attacker from forging a valid HMAC-signed request via an embedded iframe because they cannot know or reproduce the nonce and origin binding without access to the server secret.