diff --git a/app.js b/app.js index 8c87b67..1637e0a 100644 --- a/app.js +++ b/app.js @@ -97,44 +97,136 @@ app.use('/', routes); // post method to claim an address using verifymessage functionality app.post('/claim', function(req, res) { - // check if the bad-words filter is enabled - if (settings.claim_address_page.enable_bad_word_filter == true) { - // initialize the bad-words filter - var bad_word_lib = require('bad-words'); - var bad_word_filter = new bad_word_lib(); + // validate captcha if applicable + validate_captcha(settings.claim_address_page.enable_captcha, req.body, function(captcha_error) { + // check if there was a problem with captcha + if (captcha_error) { + // show the captcha error + res.json({'status': 'failed', 'error': true, 'message': 'The captcha validation failed'}); + } else { + // check if the bad-words filter is enabled + if (settings.claim_address_page.enable_bad_word_filter == true) { + // initialize the bad-words filter + var bad_word_lib = require('bad-words'); + var bad_word_filter = new bad_word_lib(); - // clean the message (Display name) of bad words - var message = (req.body.message == null || req.body.message == '' ? '' : bad_word_filter.clean(req.body.message)); - } else { - // Do not use the bad word filter - var message = (req.body.message == null || req.body.message == '' ? '' : req.body.message); - } + // clean the message (Display name) of bad words + var message = (req.body.message == null || req.body.message == '' ? '' : bad_word_filter.clean(req.body.message)); + } else { + // do not use the bad word filter + var message = (req.body.message == null || req.body.message == '' ? '' : req.body.message); + } - // check if the message was filtered - if (message == req.body.message) { - // call the verifymessage api - lib.verify_message(req.body.address, req.body.signature, req.body.message, function(body) { - if (body == false) - res.json({'status': 'failed', 'error': true, 'message': 'Invalid signature'}); - else if (body == true) { - db.update_claim_name(req.body.address, req.body.message, function(val) { - // check if the update was successful - if (val == '') - res.json({'status': 'success'}); - else if (val == 'no_address') - res.json({'status': 'failed', 'error': true, 'message': 'Wallet address ' + req.body.address + ' is not valid or does not have any transactions'}); - else + // check if the message was filtered + if (message == req.body.message) { + // call the verifymessage api + lib.verify_message(req.body.address, req.body.signature, req.body.message, function(body) { + if (body == false) + res.json({'status': 'failed', 'error': true, 'message': 'Invalid signature'}); + else if (body == true) { + db.update_claim_name(req.body.address, req.body.message, function(val) { + // check if the update was successful + if (val == '') + res.json({'status': 'success'}); + else if (val == 'no_address') + res.json({'status': 'failed', 'error': true, 'message': 'Wallet address ' + req.body.address + ' is not valid or does not have any transactions'}); + else + res.json({'status': 'failed', 'error': true, 'message': 'Wallet address or signature is invalid'}); + }); + } else res.json({'status': 'failed', 'error': true, 'message': 'Wallet address or signature is invalid'}); }); - } else - res.json({'status': 'failed', 'error': true, 'message': 'Wallet address or signature is invalid'}); - }); - } else { - // message was filtered which would change the signature - res.json({'status': 'failed', 'error': true, 'message': 'Display name contains bad words and cannot be saved: ' + message}); - } + } else { + // message was filtered which would change the signature + res.json({'status': 'failed', 'error': true, 'message': 'Display name contains bad words and cannot be saved: ' + message}); + } + } + }); }); +function validate_captcha(captcha_enabled, data, cb) { + // check if captcha is enabled for the requested feature + if (captcha_enabled == true) { + // determine the captcha type + if (settings.captcha.google_recaptcha3.enabled == true) { + if (data.google_recaptcha3 != null) { + const request = require('postman-request'); + + request({uri: 'https://www.google.com/recaptcha/api/siteverify?secret=' + settings.captcha.google_recaptcha3.secret_key + '&response=' + data.google_recaptcha3, json: true}, function (error, response, body) { + if (error) { + // an error occurred while trying to validate the captcha + return cb(true); + } else if (body == null || body == '' || typeof body !== 'object') { + // return data is invalid + return cb(true); + } else if (body.score == null || body.score < settings.captcha.google_recaptcha3.pass_score) { + // captcha challenge failed + return cb(true); + } else { + // captcha challenge passed + return cb(false); + } + }); + } else { + // a captcha response wasn't received + return cb(true); + } + } else if (settings.captcha.google_recaptcha2.enabled == true) { + if (data.google_recaptcha2 != null) { + const request = require('postman-request'); + + request({uri: 'https://www.google.com/recaptcha/api/siteverify?secret=' + settings.captcha.google_recaptcha2.secret_key + '&response=' + data.google_recaptcha2, json: true}, function (error, response, body) { + if (error) { + // an error occurred while trying to validate the captcha + return cb(true); + } else if (body == null || body == '' || typeof body !== 'object') { + // return data is invalid + return cb(true); + } else if (body.success == null || body.success == false) { + // captcha challenge failed + return cb(true); + } else { + // captcha challenge passed + return cb(false); + } + }); + } else { + // a captcha response wasn't received + return cb(true); + } + } else if (settings.captcha.hcaptcha.enabled == true) { + if (data.hcaptcha != null) { + const request = require('postman-request'); + + request({uri: 'https://hcaptcha.com/siteverify?secret=' + settings.captcha.hcaptcha.secret_key + '&response=' + data.hcaptcha, json: true}, function (error, response, body) { + if (error) { + // an error occurred while trying to validate the captcha + return cb(true); + } else if (body == null || body == '' || typeof body !== 'object') { + // return data is invalid + return cb(true); + } else if (body.success == null || body.success == false) { + // captcha challenge failed + return cb(true); + } else { + // captcha challenge passed + return cb(false); + } + }); + } else { + // a captcha response wasn't received + return cb(true); + } + } else { + // no captcha options are enabled + return cb(false); + } + } else { + // captcha is not enabled for this feature + return cb(false); + } +} + // extended apis app.use('/ext/getmoneysupply', function(req, res) { // check if the getmoneysupply api is enabled @@ -859,6 +951,7 @@ app.set('markets_page', settings.markets_page); app.set('api_page', settings.api_page); app.set('claim_address_page', settings.claim_address_page); app.set('orphans_page', settings.orphans_page); +app.set('captcha', settings.captcha); app.set('labels', settings.labels); app.set('default_coingecko_ids', settings.default_coingecko_ids); app.set('api_cmds', settings.api_cmds); diff --git a/lib/settings.js b/lib/settings.js index f79dde3..17382b4 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -1212,7 +1212,10 @@ exports.claim_address_page = { "show_header_menu": true, // enable_bad_word_filter: Enable/disable the "bad word" filter for claimed addresses, so that trying to claim an address with a bad word like "ash0le" will fail // This feature uses the default blacklist from the "bad-words" plugin from here: https://www.npmjs.com/package/bad-words - "enable_bad_word_filter": true + "enable_bad_word_filter": true, + // enable_captcha: Enable/disable using captcha security when filling out and submitting the claim address form + // NOTE: you must also configure and enable one of the options in the main "captcha" settings for this option to function correctly + "enable_captcha": false }; // orphans_page: a collection of settings that pertain to the orphans page @@ -1273,6 +1276,51 @@ exports.sync = { "supply": "GETINFO" }; +// captcha: a collection of settings that pertain to the captcha security used by different elements of the explorer +// NOTE: only 1 captcha option can be enabled and used at any given time. If you enable 2 or more options the explorer will use the first available enabled option it finds +exports.captcha = { + // google_recaptcha3: a collection of settings that pertain to using Google reCAPTCHA v3. Signup to get your site and secret keys here: https://www.google.com/recaptcha/admin + "google_recaptcha3": { + // enabled: Enable/disable the use of Google reCAPTCHA v3 (true/false) + // If set to false, Google reCAPTCHA v3 will be completely disabled as a security feature + "enabled": false, + // pass_score: A numeric score between 0.0 and 1.0 used to deteremine if a particular captcha request passed or failed + // Google reCAPTCHA v3 returns a score value for every captcha request. 1.0 is very likely a good interaction whereas 0.0 is very likely a bot + // Google recommends using 0.5 by default but you may increase the passing score if you are receiving too many automated bot submissions or lower the score if legitimate users are having troubles passing the captcha challenge + "pass_score": 0.5, + // site_key: Enter the SITE KEY value from your Google reCAPTCHA v3 settings here + "site_key": "", + // secret_key: Enter the SECRET KEY value from your Google reCAPTCHA v3 settings here + "secret_key": "" + }, + // google_recaptcha2: a collection of settings that pertain to using Google reCAPTCHA v2. Signup to get your site and secret keys here: https://www.google.com/recaptcha/admin + "google_recaptcha2": { + // enabled: Enable/disable the use of Google reCAPTCHA v2 (true/false) + // If set to false, Google reCAPTCHA v2 will be completely disabled as a security feature + "enabled": false, + // captcha_type: Determine the type of captcha to use for security validation + // Valid options: + // checkbox: The "I'm not a robot" Checkbox requires the user to click a checkbox indicating the user is not a robot. This will either pass the user immediately (with No CAPTCHA) or challenge them to validate whether or not they are human + // invisible: The invisible reCAPTCHA badge does not require the user to click on a checkbox. By default only the most suspicious traffic will be prompted to solve a captcha + "captcha_type": "checkbox", + // site_key: Enter the SITE KEY value from your Google reCAPTCHA v2 settings here + "site_key": "", + // secret_key: Enter the SECRET KEY value from your Google reCAPTCHA v2 settings here + "secret_key": "" + }, + // hcaptcha: a collection of settings that pertain to using hCaptcha. Signup to get your site and secret keys here: https://dashboard.hcaptcha.com/signup + // NOTE: Only the free "Always Challenge" mode is currently supported + "hcaptcha": { + // enabled: Enable/disable the use of hCaptcha (true/false) + // If set to false, hCaptcha will be completely disabled as a security feature + "enabled": false, + // site_key: Enter the SITE KEY value from your hCaptcha settings here + "site_key": "", + // secret_key: Enter the SECRET KEY value from your hCaptcha settings here + "secret_key": "" + } +}; + // labels: a collection of settings that pertain to the list of customized wallet address labels // Adding entries to this section will display a custom label beside each affected wallet address when displayed in the explorer // NOTE: You can add as many address labels as desired diff --git a/settings.json.template b/settings.json.template index b0b5ee4..d5a2e92 100644 --- a/settings.json.template +++ b/settings.json.template @@ -1296,7 +1296,10 @@ "show_header_menu": true, // enable_bad_word_filter: Enable/disable the "bad word" filter for claimed addresses, so that trying to claim an address with a bad word like "ash0le" will fail // This feature uses the default blacklist from the "bad-words" plugin from here: https://www.npmjs.com/package/bad-words - "enable_bad_word_filter": true + "enable_bad_word_filter": true, + // enable_captcha: Enable/disable using captcha security when filling out and submitting the claim address form + // NOTE: you must also configure and enable one of the options in the main "captcha" settings for this option to function correctly + "enable_captcha": true }, // orphans_page: a collection of settings that pertain to the orphans page @@ -1357,6 +1360,51 @@ "supply": "GETINFO" }, + // captcha: a collection of settings that pertain to the captcha security used by different elements of the explorer + // NOTE: only 1 captcha option can be enabled and used at any given time. If you enable 2 or more options the explorer will use the first available enabled option it finds + "captcha": { + // google_recaptcha3: a collection of settings that pertain to using Google reCAPTCHA v3. Signup to get your site and secret keys here: https://www.google.com/recaptcha/admin + "google_recaptcha3": { + // enabled: Enable/disable the use of Google reCAPTCHA v3 (true/false) + // If set to false, Google reCAPTCHA v3 will be completely disabled as a security feature + "enabled": false, + // pass_score: A numeric score between 0.0 and 1.0 used to deteremine if a particular captcha request passed or failed + // Google reCAPTCHA v3 returns a score value for every captcha request. 1.0 is very likely a good interaction whereas 0.0 is very likely a bot + // Google recommends using 0.5 by default but you may increase the passing score if you are receiving too many automated bot submissions or lower the score if legitimate users are having troubles passing the captcha challenge + "pass_score": 0.5, + // site_key: Enter the SITE KEY value from your Google reCAPTCHA v3 settings here + "site_key": "", + // secret_key: Enter the SECRET KEY value from your Google reCAPTCHA v3 settings here + "secret_key": "" + }, + // google_recaptcha2: a collection of settings that pertain to using Google reCAPTCHA v2. Signup to get your site and secret keys here: https://www.google.com/recaptcha/admin + "google_recaptcha2": { + // enabled: Enable/disable the use of Google reCAPTCHA v2 (true/false) + // If set to false, Google reCAPTCHA v2 will be completely disabled as a security feature + "enabled": false, + // captcha_type: Determine the type of captcha to use for security validation + // Valid options: + // checkbox: The "I'm not a robot" Checkbox requires the user to click a checkbox indicating the user is not a robot. This will either pass the user immediately (with No CAPTCHA) or challenge them to validate whether or not they are human + // invisible: The invisible reCAPTCHA badge does not require the user to click on a checkbox. By default only the most suspicious traffic will be prompted to solve a captcha + "captcha_type": "checkbox", + // site_key: Enter the SITE KEY value from your Google reCAPTCHA v2 settings here + "site_key": "", + // secret_key: Enter the SECRET KEY value from your Google reCAPTCHA v2 settings here + "secret_key": "" + }, + // hcaptcha: a collection of settings that pertain to using hCaptcha. Signup to get your site and secret keys here: https://dashboard.hcaptcha.com/signup + // NOTE: Only the free "Always Challenge" mode is currently supported + "hcaptcha": { + // enabled: Enable/disable the use of hCaptcha (true/false) + // If set to false, hCaptcha will be completely disabled as a security feature + "enabled": false, + // site_key: Enter the SITE KEY value from your hCaptcha settings here + "site_key": "", + // secret_key: Enter the SECRET KEY value from your hCaptcha settings here + "secret_key": "" + } + }, + // labels: a collection of settings that pertain to the list of customized wallet address labels // Adding entries to this section will display a custom label beside each affected wallet address when displayed in the explorer // NOTE: You can add as many address labels as desired diff --git a/views/claim_address.pug b/views/claim_address.pug index f95da9e..74f234c 100644 --- a/views/claim_address.pug +++ b/views/claim_address.pug @@ -1,27 +1,15 @@ extends layout block content + - var selected_captcha_object = null + - var selected_captcha_name = '' + if settings.claim_address_page.enable_captcha == true + each captcha, name in settings.captcha + if selected_captcha_object == null && captcha != null && captcha.enabled == true + - selected_captcha_object = captcha + - selected_captcha_name = name script. $(document).ready(function () { - function generateAlertHTML(alertClass, allowDismiss, headerText, msgText) { - return '' + - (msgText == null || msgText == '' ? '' : '' + msgText + '') + - ''; - } - function displayAsText(str) { - return str.replace(//g, '>'); - } - function showClaimAlert(claimClass, warnMsg, removedClaim) { - if ($('#claimAlert').length == 0) - $('
').insertBefore('#claimForm'); - $('#claimAlert').html(generateAlertHTML(claimClass, true, (claimClass == 'success' ? (removedClaim ? 'Address claim removed successfully' : 'Address claimed successfully') : (claimClass == 'danger' ? 'Failed to claim address' : 'Required field missing')), (claimClass == 'success' ? '' + $('input#address').val() + ' will now be referred to as ' + (removedClaim ? $('input#address').val() : displayAsText($('#message').val())) + ' throughout the website' : warnMsg) + '.')); - fixFooterHeightAndPosition(); - } $('#claimInstructions').on('show.bs.collapse', function () { $('#showClaimInstructions').html('Hide claim instructions'); }).on('hide.bs.collapse', function () { @@ -36,7 +24,8 @@ block content var address = $('input#address').val(); var message = $('input#message').val(); var signature = $('input#signature').val(); - var url = '/claim'; + var recaptcha2 = ($('#g-recaptcha-response').length > 0 ? $('#g-recaptcha-response').val() : ''); + var hcaptcha = ($('textarea[name^="h-captcha-response"]').length > 0 ? $('textarea[name^="h-captcha-response"]').val() : ''); if (address == null || address.trim().length == 0) { showClaimAlert('warning', 'Please enter the wallet address you wish to claim', false); @@ -44,19 +33,35 @@ block content } else if (signature == null || signature.trim().length == 0) { showClaimAlert('warning', 'Please enter the signature value from your wallet software', false); $('input#signature').focus(); + } else if ( + ( + '#{selected_captcha_name}' == 'google_recaptcha2' && + '#{settings.captcha.google_recaptcha2.captcha_type}' == 'checkbox' && + $('#g-recaptcha-response').length > 0 && + recaptcha2 == '' + ) || + ( + $('textarea[name^="h-captcha-response"]').length > 0 && + hcaptcha == '' + ) + ) { + showClaimAlert('warning', 'The captcha validation has not been set', false); } else { - $.ajax({ - type: 'post', - url: url, - data: { - 'address': address, - 'message': message, - 'signature': signature - }, - success: function (data) { - showClaimAlert((data.status == 'success' ? 'success' : 'danger'), data.message, (data.status == 'success' && message == '')); - } - }); + if ('#{selected_captcha_name}' == 'google_recaptcha2' && '#{settings.captcha.google_recaptcha2.captcha_type}' == 'invisible') { + grecaptcha.execute(); + } else if ('#{selected_captcha_name}' == 'google_recaptcha3') { + grecaptcha.ready(function() { + grecaptcha.execute('#{(selected_captcha_name != '' && settings.captcha[selected_captcha_name].site_key != null ? settings.captcha[selected_captcha_name].site_key : '')}', {action: 'submit'}).then(function(token) { + submitForm(token); + }); + }); + } else if (recaptcha2 != '') { + submitForm(recaptcha2); + } else if (hcaptcha != '') { + submitForm(hcaptcha); + } else { + submitForm(''); + } } }); if ('!{hash}' != 'null' && '!{hash}' != '') { @@ -66,6 +71,60 @@ block content if (#{settings.shared_pages.page_header.page_title_image.enable_animation} == true && #{settings.claim_address_page.page_header.show_img} == true) startRotateElement('img#header-img'); }); + function displayAsText(str) { + return str.replace(//g, '>'); + } + function showClaimAlert(claimClass, warnMsg, removedClaim) { + if ($('#claimAlert').length == 0) + $('
').insertBefore('#claimForm'); + $('#claimAlert').html(generateAlertHTML(claimClass, true, (claimClass == 'success' ? (removedClaim ? 'Address claim removed successfully' : 'Address claimed successfully') : (claimClass == 'danger' ? 'Failed to claim address' : 'Required field missing')), (claimClass == 'success' ? '' + $('input#address').val() + ' will now be referred to as ' + (removedClaim ? $('input#address').val() : displayAsText($('#message').val())) + ' throughout the website' : warnMsg) + '.')); + fixFooterHeightAndPosition(); + } + function generateAlertHTML(alertClass, allowDismiss, headerText, msgText) { + return '' + + (msgText == null || msgText == '' ? '' : '' + msgText + '') + + ''; + } + function submitForm(captchaData) { + const url = '/claim'; + + let postData = { + 'address': $('input#address').val(), + 'message': $('input#message').val(), + 'signature': $('input#signature').val() + }; + + if ('#{selected_captcha_name}' != '' && captchaData != '') + postData['#{selected_captcha_name}'] = captchaData; + + $.ajax({ + type: 'post', + url: url, + data: postData + }) + .done(function(data) { + showClaimAlert((data.status == 'success' ? 'success' : 'danger'), data.message, (data.status == 'success' && message == '')); + + if ( + '#{selected_captcha_name}' == 'google_recaptcha2' && + '#{settings.captcha.google_recaptcha2.captcha_type}' == 'checkbox' + ) { + // clear out the captcha to allow the form to be submitted again + grecaptcha.reset(); + } + }); + } + function onSubmit(token) { + submitForm(token); + + // ensure the onSubmit event can fire again without needing to reload the page in the event that the server returns an error and the form must be submitted again + grecaptcha.reset(); + } .col-xs-12.col-md-12 if settings.claim_address_page.page_header.show_img == true || settings.claim_address_page.page_header.show_title == true || settings.claim_address_page.page_header.show_description == true #page-header-container(style='align-items:' + (settings.claim_address_page.page_header.show_img == true && settings.claim_address_page.page_header.show_title == true && settings.claim_address_page.page_header.show_description == true ? 'flex-start' : 'center')) @@ -141,4 +200,19 @@ block content fieldset.entryField label.form-label.mt-3(for='signature') Signature input#signature.form-control.mb-3(type='text', placeholder='Signature', maxlength='100') - button.btn.btn-success(type='submit') Claim \ No newline at end of file + if settings.claim_address_page.enable_captcha == true && selected_captcha_object != null + case selected_captcha_name + when 'google_recaptcha2' + if settings.captcha.google_recaptcha2.captcha_type == 'invisible' + div#recaptcha.g-recaptcha(data-sitekey=settings.captcha[selected_captcha_name].site_key, data-callback='onSubmit' data-size='invisible') + else + .form-group + fieldset.entryField + div(class='g-recaptcha mb-3', data-sitekey=settings.captcha[selected_captcha_name].site_key) + when 'hcaptcha' + .form-group + fieldset.entryField + div.h-captcha(data-sitekey=settings.captcha[selected_captcha_name].site_key) + .form-group + fieldset.entryField + button.btn.btn-success(type='submit') Claim \ No newline at end of file diff --git a/views/layout.pug b/views/layout.pug index f87c9c4..ba9ace9 100644 --- a/views/layout.pug +++ b/views/layout.pug @@ -107,6 +107,21 @@ html(lang='en') - showNethashChart = true if settings.claim_address_page.show_difficulty_chart == true - showDifficultyChart = true + if settings.claim_address_page.enable_captcha == true + - var selected_captcha_object = null + - var selected_captcha_name = '' + each captcha, name in settings.captcha + if selected_captcha_object == null && captcha != null && captcha.enabled == true + - selected_captcha_object = captcha + - selected_captcha_name = name + if selected_captcha_object != null + case selected_captcha_name + when 'google_recaptcha3' + script(type='text/javascript', src='https://www.google.com/recaptcha/api.js?render=' + settings.captcha.google_recaptcha3.site_key, async, defer) + when 'google_recaptcha2' + script(type='text/javascript', src='https://www.google.com/recaptcha/api.js', async, defer) + when 'hcaptcha' + script(type='text/javascript', src='https://js.hcaptcha.com/1/api.js', async, defer) when 'orphans' if settings.orphans_page.show_panels == true - showPanels = true