Merge pull request #5957 from blockscout/vb-csv-server-side-captcha

Server-side reCAPTCHA check for CSV export
pull/5963/head
Victor Baranov 2 years ago committed by GitHub
commit 9e86794777
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 3
      apps/block_scout_web/assets/js/app.js
  3. 56
      apps/block_scout_web/assets/js/lib/csv_download.js
  4. 19
      apps/block_scout_web/assets/package-lock.json
  5. 1
      apps/block_scout_web/assets/package.json
  6. 2
      apps/block_scout_web/assets/webpack.config.js
  7. 2
      apps/block_scout_web/config/dev.exs
  8. 2
      apps/block_scout_web/config/prod.exs
  9. 2
      apps/block_scout_web/config/test.exs
  10. 27
      apps/block_scout_web/lib/block_scout_web/captcha_helper.ex
  11. 165
      apps/block_scout_web/lib/block_scout_web/controllers/address_transaction_controller.ex
  12. 16
      apps/block_scout_web/lib/block_scout_web/controllers/captcha_cotroller.ex
  13. 25
      apps/block_scout_web/lib/block_scout_web/templates/csv_export/index.html.eex
  14. 3
      apps/block_scout_web/lib/block_scout_web/views/captcha_view.ex
  15. 2
      apps/block_scout_web/lib/block_scout_web/web_router.ex
  16. 2
      apps/block_scout_web/priv/gettext/default.pot
  17. 2
      apps/block_scout_web/priv/gettext/en/LC_MESSAGES/default.po
  18. 81
      apps/block_scout_web/test/block_scout_web/controllers/address_transaction_controller_test.exs
  19. 2
      apps/block_scout_web/test/test_helper.exs
  20. 0
      apps/explorer/lib/explorer/chain/address_internal_transaction_csv_exporter.ex
  21. 1
      apps/explorer/lib/explorer/chain/address_transaction_csv_exporter.ex

@ -4,6 +4,7 @@
- [#5860](https://github.com/blockscout/blockscout/pull/5860) - Integrate rust verifier micro-service ([blockscout-rs/verifier](https://github.com/blockscout/blockscout-rs/tree/main/verification))
### Fixes
- [#5957](https://github.com/blockscout/blockscout/pull/5957) - Server-side reCAPTCHA check for CSV export
- [#5954](https://github.com/blockscout/blockscout/pull/5954) - Fix ace editor appearance
- [#5942](https://github.com/blockscout/blockscout/pull/5942), [#5945](https://github.com/blockscout/blockscout/pull/5945) - Fix nightly solidity versions filtering UX
- [#5904](https://github.com/blockscout/blockscout/pull/5904) - Enhance health API endpoint: better parsing HEALTHY_BLOCKS_PERIOD and use it in the response

@ -35,3 +35,6 @@ import './lib/tooltip'
import './lib/modals'
import './lib/card_tabs'
import './lib/ad'
import swal from 'sweetalert2'
window.Swal = swal

@ -1,6 +1,7 @@
import * as Pikaday from 'pikaday'
import moment from 'moment'
import $ from 'jquery'
import Cookies from 'js-cookie'
const DATE_FORMAT = 'YYYY-MM-DD'
@ -27,38 +28,33 @@ const _instance2 = new Pikaday({
})
$button.on('click', () => {
$button.addClass('spinner')
// eslint-disable-next-line
const resp = grecaptcha.getResponse()
if (resp) {
$.ajax({
url: './captcha?type=JSON',
type: 'POST',
headers: {
'x-csrf-token': $('[name=_csrf_token]').val()
},
data: {
type: 'JSON',
captchaResponse: resp
const recaptchaResponse = grecaptcha.getResponse()
if (recaptchaResponse) {
$button.addClass('spinner')
$button.prop('disabled', true)
const downloadUrl = `${$button.data('link')}&recaptcha_response=${recaptchaResponse}`
$('body').append($('<iframe id="csv-iframe" style="display: none;"></iframe>'))
$('#csv-iframe').attr('src', downloadUrl)
const interval = setInterval(handleCSVDownloaded, 1000)
setTimeout(resetDownload, 60000)
function handleCSVDownloaded () {
if (Cookies.get('csv-downloaded') === 'true') {
resetDownload()
}
})
.done(function (data) {
// eslint-disable-next-line
grecaptcha.reset()
const dataJson = JSON.parse(data)
if (dataJson.success) {
$button.removeClass('spinner')
location.href = $button.data('link')
} else {
$button.removeClass('spinner')
return false
}
})
.fail(function (_jqXHR, textStatus) {
$button.removeClass('spinner')
})
} else {
$button.removeClass('spinner')
}
function resetDownload () {
$button.removeClass('spinner')
$button.prop('disabled', false)
clearInterval(interval)
Cookies.remove('csv-downloaded')
// eslint-disable-next-line
grecaptcha.reset()
}
}
})

@ -24,6 +24,7 @@
"https-browserify": "^1.0.0",
"humps": "^2.0.1",
"jquery": "^3.4.0",
"js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8",
"lodash.differenceby": "^4.8.0",
"lodash.find": "^4.6.0",
@ -95,10 +96,11 @@
}
},
"../../../deps/phoenix": {
"version": "0.0.1"
"version": "1.5.13",
"license": "MIT"
},
"../../../deps/phoenix_html": {
"version": "0.0.1"
"version": "3.0.4"
},
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
@ -12018,6 +12020,14 @@
"optional": true,
"peer": true
},
"node_modules/js-cookie": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz",
"integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==",
"engines": {
"node": ">=12"
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
@ -27740,6 +27750,11 @@
"optional": true,
"peer": true
},
"js-cookie": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz",
"integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw=="
},
"js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",

@ -36,6 +36,7 @@
"https-browserify": "^1.0.0",
"humps": "^2.0.1",
"jquery": "^3.4.0",
"js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8",
"lodash.differenceby": "^4.8.0",
"lodash.find": "^4.6.0",

@ -69,7 +69,7 @@ const appJs =
'search-results': './js/pages/search-results/search.js',
'token-overview': './js/pages/token/overview.js',
'export-csv': './css/export-csv.scss',
'datepicker': './js/lib/datepicker.js',
'csv-download': './js/lib/csv_download.js',
'dropzone': './js/lib/dropzone.js'
},
output: {

@ -62,3 +62,5 @@ config :logger, :api,
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
config :block_scout_web, :captcha_helper, BlockScoutWeb.CaptchaHelper

@ -33,3 +33,5 @@ config :logger, :api,
path: Path.absname("logs/prod/api.log"),
metadata_filter: [fetcher: :api],
rotate: %{max_bytes: 52_428_800, keep: 19}
config :block_scout_web, :captcha_helper, BlockScoutWeb.CaptchaHelper

@ -21,3 +21,5 @@ config :logger, :block_scout_web,
config :wallaby, screenshot_on_failure: true, driver: Wallaby.Chrome, js_errors: false
config :block_scout_web, BlockScoutWeb.Counters.BlocksIndexedCounter, enabled: false
config :block_scout_web, :captcha_helper, BlockScoutWeb.TestCaptchaHelper

@ -0,0 +1,27 @@
defmodule BlockScoutWeb.CaptchaHelper do
@moduledoc """
A helper for CAPTCHA
"""
@callback recaptcha_passed?(String.t() | nil) :: bool
@spec recaptcha_passed?(String.t() | nil) :: bool
def recaptcha_passed?(nil), do: false
def recaptcha_passed?(recaptcha_response) do
re_captcha_secret_key = Application.get_env(:block_scout_web, :re_captcha_secret_key)
body = "secret=#{re_captcha_secret_key}&response=#{recaptcha_response}"
headers = [{"Content-type", "application/x-www-form-urlencoded"}]
case HTTPoison.post("https://www.google.com/recaptcha/api/siteverify", body, headers, []) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
case Jason.decode!(body) do
%{"success" => true} -> true
_ -> false
end
_ ->
false
end
end
end

@ -158,104 +158,119 @@ defmodule BlockScoutWeb.AddressTransactionController do
end
end
def token_transfers_csv(conn, %{
"address_id" => address_hash_string,
"from_period" => from_period,
"to_period" => to_period
})
when is_binary(address_hash_string) do
defp captcha_helper do
:block_scout_web
|> Application.get_env(:captcha_helper)
end
defp put_resp_params(conn, file_name) do
conn
|> put_resp_content_type("application/csv")
|> put_resp_header("content-disposition", "attachment; filename=#{file_name}")
|> put_resp_cookie("csv-downloaded", "true", max_age: 86_400, http_only: false)
|> send_chunked(200)
end
defp items_csv(
conn,
%{
"address_id" => address_hash_string,
"from_period" => from_period,
"to_period" => to_period,
"recaptcha_response" => recaptcha_response
},
csv_export_module,
file_name
)
when is_binary(address_hash_string) do
with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string),
{:ok, address} <- Chain.hash_to_address(address_hash) do
{:ok, address} <- Chain.hash_to_address(address_hash),
{:recaptcha, true} <- {:recaptcha, captcha_helper().recaptcha_passed?(recaptcha_response)} do
address
|> AddressTokenTransferCsvExporter.export(from_period, to_period)
|> Enum.into(
conn
|> put_resp_content_type("application/csv")
|> put_resp_header("content-disposition", "attachment; filename=token_transfers.csv")
|> send_chunked(200)
)
|> csv_export_module.export(from_period, to_period)
|> Enum.into(put_resp_params(conn, file_name))
else
:error ->
unprocessable_entity(conn)
{:error, :not_found} ->
not_found(conn)
{:recaptcha, false} ->
not_found(conn)
end
end
def token_transfers_csv(conn, _), do: not_found(conn)
defp items_csv(conn, _, _, _), do: not_found(conn)
def token_transfers_csv(conn, params) do
items_csv(
conn,
%{
"address_id" => params["address_id"],
"from_period" => params["from_period"],
"to_period" => params["to_period"],
"recaptcha_response" => params["recaptcha_response"]
},
AddressTokenTransferCsvExporter,
"token_transfers.csv"
)
end
def transactions_csv(conn, %{
"address_id" => address_hash_string,
"from_period" => from_period,
"to_period" => to_period
"to_period" => to_period,
"recaptcha_response" => recaptcha_response
}) do
with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string),
{:ok, address} <- Chain.hash_to_address(address_hash) do
address
|> AddressTransactionCsvExporter.export(from_period, to_period)
|> Enum.into(
conn
|> put_resp_content_type("application/csv")
|> put_resp_header("content-disposition", "attachment; filename=transactions.csv")
|> send_chunked(200)
)
else
:error ->
unprocessable_entity(conn)
{:error, :not_found} ->
not_found(conn)
end
items_csv(
conn,
%{
"address_id" => address_hash_string,
"from_period" => from_period,
"to_period" => to_period,
"recaptcha_response" => recaptcha_response
},
AddressTransactionCsvExporter,
"transactions.csv"
)
end
def transactions_csv(conn, _), do: not_found(conn)
def internal_transactions_csv(conn, %{
"address_id" => address_hash_string,
"from_period" => from_period,
"to_period" => to_period
"to_period" => to_period,
"recaptcha_response" => recaptcha_response
}) do
with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string),
{:ok, address} <- Chain.hash_to_address(address_hash) do
address
|> AddressInternalTransactionCsvExporter.export(from_period, to_period)
|> Enum.into(
conn
|> put_resp_content_type("application/csv")
|> put_resp_header("content-disposition", "attachment; filename=internal_transactions.csv")
|> send_chunked(200)
)
else
:error ->
unprocessable_entity(conn)
{:error, :not_found} ->
not_found(conn)
end
items_csv(
conn,
%{
"address_id" => address_hash_string,
"from_period" => from_period,
"to_period" => to_period,
"recaptcha_response" => recaptcha_response
},
AddressInternalTransactionCsvExporter,
"internal_transactions.csv"
)
end
def internal_transactions_csv(conn, _), do: not_found(conn)
def logs_csv(conn, %{"address_id" => address_hash_string, "from_period" => from_period, "to_period" => to_period}) do
with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string),
{:ok, address} <- Chain.hash_to_address(address_hash) do
address
|> AddressLogCsvExporter.export(from_period, to_period)
|> Enum.into(
conn
|> put_resp_content_type("application/csv")
|> put_resp_header("content-disposition", "attachment; filename=logs.csv")
|> send_chunked(200)
)
else
:error ->
unprocessable_entity(conn)
{:error, :not_found} ->
not_found(conn)
end
def logs_csv(conn, %{
"address_id" => address_hash_string,
"from_period" => from_period,
"to_period" => to_period,
"recaptcha_response" => recaptcha_response
}) do
items_csv(
conn,
%{
"address_id" => address_hash_string,
"from_period" => from_period,
"to_period" => to_period,
"recaptcha_response" => recaptcha_response
},
AddressLogCsvExporter,
"logs.csv"
)
end
def logs_csv(conn, _), do: not_found(conn)
end

@ -1,16 +0,0 @@
defmodule BlockScoutWeb.CaptchaController do
use BlockScoutWeb, :controller
alias Plug.Conn
def index(conn, %{"captchaResponse" => captcha_response, "type" => "JSON"}) do
body = "secret=#{Application.get_env(:block_scout_web, :re_captcha_secret_key)}&response=#{captcha_response}"
headers = [{"Content-type", "application/x-www-form-urlencoded"}]
case HTTPoison.post("https://www.google.com/recaptcha/api/siteverify", body, headers, []) do
{:ok, %HTTPoison.Response{status_code: status_code, body: body}} ->
Conn.resp(conn, status_code, body)
end
end
end

@ -21,21 +21,32 @@
</div>
<div id="recaptcha" class=mb-3></div>
<input type="hidden" name="_csrf_token" value="<%= Plug.CSRFProtection.get_csrf_token() %>">
<button id="export-csv-button" class="button button-primary" style="padding: 10px 25px;" data-link=<%= address_transaction_path(@conn, type_download_path(@type), %{"address_id" => address_checksum(@address_hash_string), "from_period" => default_period_start(), "to_period" => default_period_end()}) %>><%= gettext("Download") %></button>
<button id="export-csv-button" class="button button-primary" disabled style="padding: 10px 25px;" data-link=<%= address_transaction_path(@conn, type_download_path(@type), %{"address_id" => address_checksum(@address_hash_string), "from_period" => default_period_start(), "to_period" => default_period_end()}) %>><%= gettext("Download") %></button>
</a>
</div>
</div>
<script type="text/javascript">
var widgetId1
var onloadCallback = function() {
widgetId1 = grecaptcha.render('recaptcha', {
'sitekey': '<%= Application.get_env(:block_scout_web, :re_captcha_client_key) %>',
'theme': localStorage.getItem('current-color-mode')
})
var reCaptchaClientKey = '<%= Application.get_env(:block_scout_web, :re_captcha_client_key) %>'
if (reCaptchaClientKey) {
widgetId1 = grecaptcha.render('recaptcha', {
'sitekey': reCaptchaClientKey,
'theme': localStorage.getItem('current-color-mode'),
'callback': function () {
document.getElementById('export-csv-button').disabled = false
}
})
} else {
Swal.fire({
title: 'Warning',
html: 'CSV download will not work since reCAPTCHA is not configured. Please advise server maintainer to configure RE_CAPTCHA_CLIENT_KEY and RE_CAPTCHA_SECRET_KEY environment variables.',
icon: 'warning'
})
}
}
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer>
</script>
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/datepicker.js") %>"></script>
<script defer data-cfasync="false" src="<%= static_path(@conn, "/js/csv-download.js") %>"></script>
</section>

@ -1,3 +0,0 @@
defmodule BlockScoutWeb.CaptchaView do
use BlockScoutWeb, :view
end

@ -402,8 +402,6 @@ defmodule BlockScoutWeb.WebRouter do
get("/csv-export", CsvExportController, :index)
post("/captcha", CaptchaController, :index)
get("/transactions-csv", AddressTransactionController, :transactions_csv)
get("/token-autocomplete", ChainController, :token_autocomplete)

@ -811,7 +811,7 @@ msgstr ""
msgid "Displaying the init data provided of the creating transaction."
msgstr ""
#: lib/block_scout_web/templates/csv_export/index.html.eex:25
#: lib/block_scout_web/templates/csv_export/index.html.eex:24
#, elixir-autogen, elixir-format
msgid "Download"
msgstr ""

@ -811,7 +811,7 @@ msgstr ""
msgid "Displaying the init data provided of the creating transaction."
msgstr ""
#: lib/block_scout_web/templates/csv_export/index.html.eex:25
#: lib/block_scout_web/templates/csv_export/index.html.eex:24
#, elixir-autogen, elixir-format
msgid "Download"
msgstr ""

@ -8,6 +8,8 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do
alias Explorer.Chain.{Address, Transaction}
alias Explorer.ExchangeRates.Token
setup :verify_on_exit!
describe "GET index/2" do
setup :set_mox_global
@ -159,7 +161,10 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do
end
describe "GET token-transfers-csv/2" do
test "exports token transfers to csv", %{conn: conn} do
test "do not export token transfers to csv without recaptcha recaptcha_response provided", %{conn: conn} do
BlockScoutWeb.TestCaptchaHelper
|> expect(:recaptcha_passed?, fn _captcha_response -> false end)
address = insert(:address)
transaction =
@ -180,12 +185,71 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do
"to_period" => to_period
})
assert conn.status == 404
end
test "do not export token transfers to csv without recaptcha passed", %{conn: conn} do
BlockScoutWeb.TestCaptchaHelper
|> expect(:recaptcha_passed?, fn _captcha_response -> false end)
address = insert(:address)
transaction =
:transaction
|> insert(from_address: address)
|> with_block()
insert(:token_transfer, transaction: transaction, from_address: address, block_number: transaction.block_number)
insert(:token_transfer, transaction: transaction, to_address: address, block_number: transaction.block_number)
from_period = Timex.format!(Timex.shift(Timex.now(), minutes: -1), "%Y-%m-%d", :strftime)
to_period = Timex.format!(Timex.now(), "%Y-%m-%d", :strftime)
conn =
get(conn, "/token-transfers-csv", %{
"address_id" => Address.checksum(address.hash),
"from_period" => from_period,
"to_period" => to_period,
"recaptcha_response" => "123"
})
assert conn.status == 404
end
test "exports token transfers to csv", %{conn: conn} do
BlockScoutWeb.TestCaptchaHelper
|> expect(:recaptcha_passed?, fn _captcha_response -> true end)
address = insert(:address)
transaction =
:transaction
|> insert(from_address: address)
|> with_block()
insert(:token_transfer, transaction: transaction, from_address: address, block_number: transaction.block_number)
insert(:token_transfer, transaction: transaction, to_address: address, block_number: transaction.block_number)
from_period = Timex.format!(Timex.shift(Timex.now(), minutes: -1), "%Y-%m-%d", :strftime)
to_period = Timex.format!(Timex.now(), "%Y-%m-%d", :strftime)
conn =
get(conn, "/token-transfers-csv", %{
"address_id" => Address.checksum(address.hash),
"from_period" => from_period,
"to_period" => to_period,
"recaptcha_response" => "123"
})
assert conn.resp_body |> String.split("\n") |> Enum.count() == 4
end
end
describe "GET transactions_csv/2" do
test "download csv file with transactions", %{conn: conn} do
BlockScoutWeb.TestCaptchaHelper
|> expect(:recaptcha_passed?, fn _captcha_response -> true end)
address = insert(:address)
:transaction
@ -203,7 +267,8 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do
get(conn, "/transactions-csv", %{
"address_id" => Address.checksum(address.hash),
"from_period" => from_period,
"to_period" => to_period
"to_period" => to_period,
"recaptcha_response" => "123"
})
assert conn.resp_body |> String.split("\n") |> Enum.count() == 4
@ -212,6 +277,9 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do
describe "GET internal_transactions_csv/2" do
test "download csv file with internal transactions", %{conn: conn} do
BlockScoutWeb.TestCaptchaHelper
|> expect(:recaptcha_passed?, fn _captcha_response -> true end)
address = insert(:address)
transaction_1 =
@ -266,7 +334,8 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do
get(conn, "/internal-transactions-csv", %{
"address_id" => Address.checksum(address.hash),
"from_period" => from_period,
"to_period" => to_period
"to_period" => to_period,
"recaptcha_response" => "123"
})
assert conn.resp_body |> String.split("\n") |> Enum.count() == 5
@ -275,6 +344,9 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do
describe "GET logs_csv/2" do
test "download csv file with logs", %{conn: conn} do
BlockScoutWeb.TestCaptchaHelper
|> expect(:recaptcha_passed?, fn _captcha_response -> true end)
address = insert(:address)
transaction_1 =
@ -323,7 +395,8 @@ defmodule BlockScoutWeb.AddressTransactionControllerTest do
get(conn, "/logs-csv", %{
"address_id" => Address.checksum(address.hash),
"from_period" => from_period,
"to_period" => to_period
"to_period" => to_period,
"recaptcha_response" => "123"
})
assert conn.resp_body |> String.split("\n") |> Enum.count() == 5

@ -21,3 +21,5 @@ Ecto.Adapters.SQL.Sandbox.mode(Explorer.Repo, :manual)
Absinthe.Test.prime(BlockScoutWeb.Schema)
Mox.defmock(EthereumJSONRPC.Mox, for: EthereumJSONRPC.Transport)
Mox.defmock(BlockScoutWeb.TestCaptchaHelper, for: BlockScoutWeb.CaptchaHelper)

@ -51,7 +51,6 @@ defmodule Explorer.Chain.AddressTransactionCsvExporter do
|> Keyword.put(:to_block, to_block)
transactions = Chain.address_to_transactions_without_rewards(address_hash, options)
new_acc = transactions ++ acc
case Enum.split(transactions, @page_size) do

Loading…
Cancel
Save