Skip to content

Add config to disable CSRF + conflict check with CORS#1571

Merged
karriebear merged 14 commits intostreamlit:developfrom
karriebear:feature-1565
Jun 20, 2020
Merged

Add config to disable CSRF + conflict check with CORS#1571
karriebear merged 14 commits intostreamlit:developfrom
karriebear:feature-1565

Conversation

@karriebear
Copy link
Copy Markdown
Contributor

@karriebear karriebear commented Jun 12, 2020

Issue: Fixes #1565

Description:
This adds a configuration to enable streamlit developers to disable CSRF. Because this configuration can be changed, updated communications between server and client side to update _xsrf cookie and HTTP settings required for CSRF.

  1. Server side: Add a config to toggle CSRF. By default, CSRF is enabled.
  2. Server side: Add a conflict check for CORS and CSRF
  3. Session Communication: When disconnected, GET /healthz will be called. Update endpoint to set xsrf cookie.
  4. Client side: Create a base HttpRequest class to handle settings supporting CSRF
  5. Client side: Update HttpRequest CSRF upon initialization
  6. Client side: On connection state change, clear xsrf cookie. This handles the case when server is updated to disable CSRF.

Notes
To support CSRF, the following headers are required in http requests:
self.set_header("Access-Control-Allow-Headers", "X-Xsrftoken")

In order to send cookies in http requests, the following must be done:

  1. withCredentials must be allowed and set to true.
  2. Access-Control-Allow-Origin cannot be *

In order to validate requests with the cookie to header approach:

  1. Send cookie with CSRF token. Tornado sets the cookie under _xsrf
  2. Ensure header for token is allowed and sent. Tornado uses the header X-XSRFToken.

Contribution License Agreement

By submiting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

Copy link
Copy Markdown
Contributor Author

@karriebear karriebear Jun 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This default seems backwards to me. In routes.py we check if CORS is allowed by using not config.get_option("server.enableCORS"). Left it as is for now 🤷‍♀️

def allow_cross_origin_requests():
    """True if cross-origin requests are allowed.

    We only allow cross-origin requests when using the Node server. This is
    only needed when using the Node server anyway, since in that case we
    have a dev port and the prod port, which count as two origins.

    """
    return not config.get_option("server.enableCORS") or config.get_option(
        "global.useNode"
    )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point re the login in allow_cross_origin_requests(). For the sake of this file - would it be possible to minimize code change by retaining the location of server.enableCORS and moving server.enableCSRF alongside it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do that. I was being silly and slowly alphabetizing them.

@karriebear karriebear marked this pull request as ready for review June 12, 2020 23:43
@karriebear karriebear requested a review from a team as a code owner June 12, 2020 23:43
@karriebear karriebear marked this pull request as draft June 12, 2020 23:45
@karriebear karriebear requested a review from Amey-D June 15, 2020 14:43
@karriebear karriebear force-pushed the feature-1565 branch 4 times, most recently from 95206a8 to 047e322 Compare June 15, 2020 16:06
@karriebear karriebear marked this pull request as ready for review June 15, 2020 17:21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point re the login in allow_cross_origin_requests(). For the sake of this file - would it be possible to minimize code change by retaining the location of server.enableCORS and moving server.enableCSRF alongside it?


if get_option("server.enableCSRF"):
if not get_option("server.enableCORS") or get_option("global.useNode"):
LOGGER.warning(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it sufficient to just issue a warning here? For example, in Server.py, we access self.xsrf_token when server.enableCSRF is enabled. Also in UploadFileRequestHandler.py it looks like server.enableCSRF takes precedence over allow_cross_origin_requests(). If both CSRF and CORS are enabled, we should either fail loudly, or preferably we should choose prefer one of the two along with a clear warning message?

Copy link
Copy Markdown
Contributor Author

@karriebear karriebear Jun 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated the warning messaging indicating server.enableCSRF will take precedence. Let me know if it's not clear!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning message LGTM

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think this warning could be reworded (given the long internal conversation about how confusing server.enableCORS can be) as follows:

"Currently, CSRF protection is incompatible with Cross-Origin Resource Sharing (CORS). If you are enabling `server.enableCSRF` and disabling `server.enableCORS` options at the same time, we will prioritize `server.enableCSRF`. If cross origin POST, PUT, PATCH or DELETE requests are required, please disable 'server.enableCSRF'."

@karriebear karriebear requested a review from tconkling June 17, 2020 17:52
Copy link
Copy Markdown
Contributor

@tconkling tconkling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few cookie-related feedbacks/questions!

Comment on lines +46 to +54
public updateCsrfToken(): void {
const csrfCookie = getCookie("_xsrf")
if (csrfCookie) {
this.axiosInstance.defaults.headers["X-Xsrftoken"] = csrfCookie
this.axiosInstance.defaults.withCredentials = true
} else {
delete this.axiosInstance.defaults.headers["X-Xsrftoken"]
this.axiosInstance.defaults.withCredentials = false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know the ins and outs of CSRF, so this might be nonsense, but: I think this might be the wrong approach. Currently, if I'm understanding correctly:

  • There are circumstances where a CSRF token needs to be updated
  • When this happens, we need to manually call updateCsrfToken() for all HTTPClient instances. (It looks like we only have one, right now, but HTTPClient is a base class, not a singleton, so this won't necessarily always hold.)

This means we're introducing new state that we need to remember to manually update. Since this is just for setting headers on requests, do we actually need this state - that is, can we just set the CSRF header reactively, instead? For example, we could have an HTTPClient.request method that just wraps axios.request, and adds this header as appropriate.

public request(params: AxiosRequestConfig) {
  // If we have a CSRF cookie, add it to every outgoing request
  const cookie = getCookie("_xsrf")
  if (cookie != null) {
    params.headers["X-Xsrftoken"] = cookie
    params.withCredentials = true
  }
  return axios.request(params)
}

Then, FileUploadClient and any other HTTPClient instances would do this.request(...) instead of axios.request(...), and nobody has to remember to manually update a token anywhere?

Copy link
Copy Markdown
Contributor Author

@karriebear karriebear Jun 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I debated about this for quite a bit and ended up going with this approach to provide more flexibility.

On the server side we have to update the allowable headers for each request handler so its possible we could have CSRF enabled on some and not others (currently the case though none of the existing has a corresponding http client). I'm assuming we'll end up mapping each new HTTPClient to a different tornado.RequestHandler. In that regards, if we create a new RequestHandler without specifying the necessary allowable headers, the HTTPClient will not cry (assuming we wouldn't have called updateCsrfToken for the new HTTPClient as well). This could be a completely moot use case I'm accommodating for. Let me know what you think and happy to change!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite follow, but I still think that being stateless is accomplishable and desirable. (Though again, I might be misunderstanding something!)

If the CSRF header is a per-client concern, then we could have "useCSRFToken" as a parameter that gets passed to the constructor, and the code above becomes:

// (in class HTTPClient):
public request(params: AxiosRequestConfig) {
  if (this.useCSRFToken) {
      const cookie = getCookie("_xsrf")
      if (cookie != null) {
        params.headers["X-Xsrftoken"] = cookie
        params.withCredentials = true
      }
  }
  return axios.request(params)
}

(It's the stateful updateCsrfToken() function that I'm objecting to.)

Copy link
Copy Markdown
Contributor Author

@karriebear karriebear Jun 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't think about passing it at the constructor level. 🤦‍♀️ I only thought about passing it at the request level and was too saddened by that. I'll update!

Comment on lines +105 to +110
afterEach(() => {
document.cookie.split(";").forEach(cookie => {
const eqPos = cookie.indexOf("=")
const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand this code. Can you add a comment explaining why it's needed? (Also, it looks like each iteration through this loop completely rewrites document.cookie, which possibly means that only the last iteration is needed?)

@karriebear karriebear requested a review from Amey-D June 18, 2020 20:46

if get_option("server.enableCSRF"):
if not get_option("server.enableCORS") or get_option("global.useNode"):
LOGGER.warning(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning message LGTM

@karriebear karriebear merged commit 73dc179 into streamlit:develop Jun 20, 2020

@_create_option("server.enableCSRFProtection", type_=bool)
def _server_enable_CSRF():
"""Enables support for protection from Cross-site request forgery attacks. Requires CORS to be disabled.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "requires CORS to be disabled" mean?

"server.enableCSRFProtection is not compatible with server.enableCORS. "
"We will prioritize server.enableCSRFProtection over server.enableCORS "
"where CSRF has been enabled. If cross origin POST, PUT or "
"DELETE requests are required, please disable 'server.enableCSRFProtection'"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If cross origin POST, PUT or DELETE requests are required, please disable 'server.enableCSRFProtection'

What does this mean? IIUC this is not a helpful message as the developer has no idea if what HTTP verbs we're using...

tconkling added a commit that referenced this pull request Jun 22, 2020
* develop:
  Formatting changes from `make pyformat` (#1621)
  Add config to disable CSRF + conflict check with CORS (#1571)
  MediaFile Grace Period using KEEP_DELAY = 5 seconds (#1494)
  Fix server.cookieSecret config option. (#1603)
  Create a config option to toggle websocket compression (#1544)
  Remove Python 2 and Python 3.5 staleness (#1600)
  Disable/Remove scroll when in full screen mode (#1586)
  Better welcome message when running `streamlit hello` (#1585)
  Scale image to approximate size on screen (#1594)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create a config option to toggle XSRF in Streamlit server

4 participants