diff --git a/.circleci/docker-compose.cypress.yml b/.circleci/docker-compose.cypress.yml index b0370a0572..f058d652e4 100644 --- a/.circleci/docker-compose.cypress.yml +++ b/.circleci/docker-compose.cypress.yml @@ -14,6 +14,7 @@ services: REDASH_REDIS_URL: "redis://redis:6379/0" REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres" REDASH_RATELIMIT_ENABLED: "false" + REDASH_ENFORCE_CSRF: "true" scheduler: build: ../ command: scheduler diff --git a/client/app/services/axios.js b/client/app/services/axios.js index 3697050bbe..1f0d55f27e 100644 --- a/client/app/services/axios.js +++ b/client/app/services/axios.js @@ -1,6 +1,7 @@ import axiosLib from "axios"; import { Auth } from "@/services/auth"; import qs from "query-string"; +import Cookies from "js-cookie"; export const axios = axiosLib.create({ paramsSerializer: params => qs.stringify(params), @@ -14,6 +15,11 @@ axios.interceptors.request.use(config => { const apiKey = Auth.getApiKey(); if (apiKey) { config.headers.Authorization = `Key ${apiKey}`; + } else { + const csrfToken = Cookies.get("csrf_token"); + if (csrfToken) { + config.headers.common["X-CSRF-TOKEN"] = csrfToken; + } } return config; diff --git a/client/cypress/cypress.js b/client/cypress/cypress.js index 9a8611fecf..67ddf15de0 100644 --- a/client/cypress/cypress.js +++ b/client/cypress/cypress.js @@ -1,21 +1,37 @@ /* eslint-disable import/no-extraneous-dependencies, no-console */ +const { find } = require("lodash"); const atob = require("atob"); const { execSync } = require("child_process"); -const { post } = require("request").defaults({ jar: true }); +const { get, post } = require("request").defaults({ jar: true }); const { seedData } = require("./seed-data"); +var Cookie = require("request-cookies").Cookie; const baseUrl = process.env.CYPRESS_baseUrl || "http://localhost:5000"; function seedDatabase(seedValues) { - const request = seedValues.shift(); - const data = request.type === "form" ? { formData: request.data } : { json: request.data }; + get(baseUrl + "/login", (_, { headers }) => { + const request = seedValues.shift(); + const data = request.type === "form" ? { formData: request.data } : { json: request.data }; - post(baseUrl + request.route, data, (err, response) => { - const result = response ? response.statusCode : err; - console.log("POST " + request.route + " - " + result); - if (seedValues.length) { - seedDatabase(seedValues); + if (headers["set-cookie"]) { + const cookies = headers["set-cookie"].map(cookie => new Cookie(cookie)); + const csrfCookie = find(cookies, { key: "csrf_token" }); + if (csrfCookie) { + if (request.type === "form") { + data["formData"] = { ...data["formData"], csrf_token: csrfCookie.value }; + } else { + data["headers"] = { "X-CSRFToken": csrfCookie.value }; + } + } } + + post(baseUrl + request.route, data, (err, response) => { + const result = response ? response.statusCode : err; + console.log("POST " + request.route + " - " + result); + if (seedValues.length) { + seedDatabase(seedValues); + } + }); }); } diff --git a/client/cypress/support/commands.js b/client/cypress/support/commands.js index 1ac5aef0ec..a9245f3f1c 100644 --- a/client/cypress/support/commands.js +++ b/client/cypress/support/commands.js @@ -4,17 +4,36 @@ import "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencie const { each } = Cypress._; -Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => - cy.request({ - url: "/login", - method: "POST", - form: true, - body: { - email, - password, - }, - }) -); +Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => { + let csrf; + cy.visit("/login"); + cy.getCookie("csrf_token") + .then(cookie => { + if (cookie) { + csrf = cookie.value; + } else { + cy.visit("/login").then(() => { + cy.get('input[name="csrf_token"]') + .invoke("val") + .then(csrf_token => { + csrf = csrf_token; + }); + }); + } + }) + .then(() => { + cy.request({ + url: "/login", + method: "POST", + form: true, + body: { + email, + password, + csrf_token: csrf, + }, + }); + }); +}); Cypress.Commands.add("logout", () => cy.visit("/logout")); Cypress.Commands.add("getByTestId", element => cy.get('[data-test="' + element + '"]')); diff --git a/client/cypress/support/redash-api/index.js b/client/cypress/support/redash-api/index.js index 647ff620cc..40021c9fcb 100644 --- a/client/cypress/support/redash-api/index.js +++ b/client/cypress/support/redash-api/index.js @@ -2,8 +2,13 @@ const { extend, get, merge, find } = Cypress._; +const post = options => + cy + .getCookie("csrf_token") + .then(csrf => cy.request({ ...options, method: "POST", headers: { "X-CSRF-TOKEN": csrf.value } })); + export function createDashboard(name) { - return cy.request("POST", "api/dashboards", { name }).then(({ body }) => body); + return post({ url: "api/dashboards", body: { name } }).then(({ body }) => body); } export function createQuery(data, shouldPublish = true) { @@ -21,10 +26,10 @@ export function createQuery(data, shouldPublish = true) { ); // eslint-disable-next-line cypress/no-assigning-return-values - let request = cy.request("POST", "/api/queries", merged).then(({ body }) => body); + let request = post({ url: "/api/queries", body: merged }).then(({ body }) => body); if (shouldPublish) { request = request.then(query => - cy.request("POST", `/api/queries/${query.id}`, { is_draft: false }).then(() => query) + post({ url: `/api/queries/${query.id}`, body: { is_draft: false } }).then(() => query) ); } @@ -33,7 +38,7 @@ export function createQuery(data, shouldPublish = true) { export function createVisualization(queryId, type, name, options) { const data = { query_id: queryId, type, name, options }; - return cy.request("POST", "/api/visualizations", data).then(({ body }) => ({ + return post({ url: "/api/visualizations", body: data }).then(({ body }) => ({ query_id: queryId, ...body, })); @@ -52,7 +57,7 @@ export function addTextbox(dashboardId, text = "text", options = {}) { options: merge(defaultOptions, options), }; - return cy.request("POST", "api/widgets", data).then(({ body }) => { + return post({ url: "api/widgets", body: data }).then(({ body }) => { const id = get(body, "id"); assert.isDefined(id, "Widget api call returns widget id"); return body; @@ -71,7 +76,7 @@ export function addWidget(dashboardId, visualizationId, options = {}) { options: merge(defaultOptions, options), }; - return cy.request("POST", "api/widgets", data).then(({ body }) => { + return post({ url: "api/widgets", body: data }).then(({ body }) => { const id = get(body, "id"); assert.isDefined(id, "Widget api call returns widget id"); return body; @@ -92,47 +97,42 @@ export function createAlert(queryId, options = {}, name) { options: merge(defaultOptions, options), }; - return cy.request("POST", "api/alerts", data).then(({ body }) => { + return post({ url: "api/alerts", body: data }).then(({ body }) => { const id = get(body, "id"); - assert.isDefined(id, "Alert api call returns alert id"); + assert.isDefined(id, "Alert api call retu ns alert id"); return body; }); } export function createUser({ name, email, password }) { - return cy - .request({ - method: "POST", - url: "api/users?no_invite=yes", - body: { name, email }, - failOnStatusCode: false, - }) - .then(xhr => { - const { status, body } = xhr; - if (status < 200 || status > 400) { - throw new Error(xhr); - } + return post({ + url: "api/users?no_invite=yes", + body: { name, email }, + failOnStatusCode: false, + }).then(xhr => { + const { status, body } = xhr; + if (status < 200 || status > 400) { + throw new Error(xhr); + } - if (status === 400 && body.message === "Email already taken.") { - // all is good, do nothing - return; - } + if (status === 400 && body.message === "Email already taken.") { + // all is good, do nothing + return; + } - const id = get(body, "id"); - assert.isDefined(id, "User api call returns user id"); + const id = get(body, "id"); + assert.isDefined(id, "User api call returns user id"); - return cy.request({ - url: body.invite_link, - method: "POST", - form: true, - body: { password }, - }); + return post({ + url: body.invite_link, + form: true, + body: { password }, }); + }); } export function createDestination(name, type, options = {}) { - return cy.request({ - method: "POST", + return post({ url: "api/destinations", body: { name, type, options }, failOnStatusCode: false, @@ -150,9 +150,12 @@ export function addDestinationSubscription(alertId, destinationName) { if (!destination) { throw new Error("Destination not found"); } - return cy.request("POST", `api/alerts/${alertId}/subscriptions`, { - alert_id: alertId, - destination_id: destination.id, + return post({ + url: `api/alerts/${alertId}/subscriptions`, + body: { + alert_id: alertId, + destination_id: destination.id, + }, }); }) .then(({ body }) => { diff --git a/docker-compose.yml b/docker-compose.yml index bef7197a1c..82bcd245fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ x-redash-environment: &redash-environment REDASH_RATELIMIT_ENABLED: "false" REDASH_MAIL_DEFAULT_SENDER: "redash@example.com" REDASH_MAIL_SERVER: "email" + REDASH_ENFORCE_CSRF: "true" services: server: <<: *redash-service diff --git a/package-lock.json b/package-lock.json index 6a0c300935..c6c1c40109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12215,6 +12215,11 @@ "integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==", "dev": true }, + "js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -16669,6 +16674,26 @@ } } }, + "request-cookies": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/request-cookies/-/request-cookies-1.1.0.tgz", + "integrity": "sha1-dYHT2bKsXcQ25kWbIWpi48fmjPE=", + "dev": true, + "requires": { + "tough-cookie": "0.12.x" + }, + "dependencies": { + "tough-cookie": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-0.12.1.tgz", + "integrity": "sha1-giDH4hq9WxPZaAQlS9WoHr8sfWI=", + "dev": true, + "requires": { + "punycode": ">=0.2.0" + } + } + } + }, "request-promise-core": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", diff --git a/package.json b/package.json index 72678fe7d6..f578a9dce8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "jest": "TZ=Africa/Khartoum jest", "test": "run-s type-check jest", "test:watch": "jest --watch", - "cypress:install": "npm install --no-save cypress@~4.5.0 @percy/agent@0.26.2 @percy/cypress@^2.2.0 atob@2.1.2", + "cypress:install": "npm install --no-save cypress@~4.5.0 @percy/agent@0.26.2 @percy/cypress@^2.2.0 atob@2.1.2 lodash@^4.17.10 request-cookies@^1.1.0", "cypress": "node client/cypress/cypress.js", "postinstall": "(cd viz-lib && npm ci && npm run build:babel)" }, @@ -57,6 +57,7 @@ "font-awesome": "^4.7.0", "history": "^4.10.1", "hoist-non-react-statics": "^3.3.0", + "js-cookie": "^2.2.1", "lodash": "^4.17.10", "markdown": "0.5.0", "material-design-iconic-font": "^2.2.0", @@ -130,6 +131,7 @@ "raw-loader": "^0.5.1", "react-test-renderer": "^16.5.2", "request": "^2.88.0", + "request-cookies": "^1.1.0", "typescript": "^3.9.6", "url-loader": "^1.1.2", "webpack": "^4.20.2", diff --git a/redash/security.py b/redash/security.py index dd14441385..181ebc26fe 100644 --- a/redash/security.py +++ b/redash/security.py @@ -1,10 +1,15 @@ import functools +from flask import session +from flask_login import current_user from flask_talisman import talisman +from flask_wtf.csrf import CSRFProtect, generate_csrf + from redash import settings talisman = talisman.Talisman() +csrf = CSRFProtect() def csp_allows_embeding(fn): @@ -19,6 +24,20 @@ def decorated(*args, **kwargs): def init_app(app): + csrf.init_app(app) + app.config["WTF_CSRF_CHECK_DEFAULT"] = False + + @app.after_request + def inject_csrf_token(response): + response.set_cookie("csrf_token", generate_csrf()) + return response + + if settings.ENFORCE_CSRF: + @app.before_request + def check_csrf(): + if not current_user.is_authenticated or 'user_id' in session: + csrf.protect() + talisman.init_app( app, feature_policy=settings.FEATURE_POLICY, diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index ebe2ee0a26..2aca85b3b7 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -500,3 +500,9 @@ def email_server_is_configured(): REQUESTS_ALLOW_REDIRECTS = parse_boolean( os.environ.get("REDASH_REQUESTS_ALLOW_REDIRECTS", "false") ) + +# Enforces CSRF token validation on API requests. +# This is turned off by default to avoid breaking any existing deployments but it is highly recommended to turn this toggle on to prevent CSRF attacks. +ENFORCE_CSRF = parse_boolean( + os.environ.get("REDASH_ENFORCE_CSRF", "false") +) \ No newline at end of file diff --git a/redash/templates/forgot.html b/redash/templates/forgot.html index 54ff1bde48..f8ee82e1f4 100644 --- a/redash/templates/forgot.html +++ b/redash/templates/forgot.html @@ -9,6 +9,7 @@ {% else %}
+
diff --git a/redash/templates/invite.html b/redash/templates/invite.html index 21705c79a4..e52dbe4b41 100644 --- a/redash/templates/invite.html +++ b/redash/templates/invite.html @@ -45,6 +45,7 @@ {% endif %} +
diff --git a/redash/templates/login.html b/redash/templates/login.html index 71bc8b4aec..b9fc1b3abe 100644 --- a/redash/templates/login.html +++ b/redash/templates/login.html @@ -38,6 +38,7 @@ {% endif %} +
diff --git a/redash/templates/reset.html b/redash/templates/reset.html index 36dd3ebee9..c4816a05af 100644 --- a/redash/templates/reset.html +++ b/redash/templates/reset.html @@ -13,6 +13,7 @@ {% endif %} {% endwith %} +
diff --git a/redash/templates/setup.html b/redash/templates/setup.html index 49268a9bcf..4f94a6b513 100644 --- a/redash/templates/setup.html +++ b/redash/templates/setup.html @@ -36,6 +36,7 @@

Welcome to Redash!

{% endwith %} +

Admin User

{{ render_field(form.name) }} {{ render_field(form.email) }} diff --git a/requirements.txt b/requirements.txt index 4117b0f552..e8ca893193 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ Flask-Migrate==2.5.2 flask-mail==0.9.1 flask-talisman==0.7.0 Flask-Limiter==0.9.3 +Flask-WTF==0.14.3 passlib==1.7.1 aniso8601==8.0.0 blinker==1.4 diff --git a/tests/__init__.py b/tests/__init__.py index c75c6b08ae..c16cad55a9 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -20,6 +20,8 @@ # Make sure rate limit is enabled os.environ["REDASH_RATELIMIT_ENABLED"] = "true" +os.environ["REDASH_ENFORCE_CSRF"] = "false" + from redash import limiter, redis_connection from redash.app import create_app from redash.models import db