Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MM-10346] CSRF Token Implementation for Plugins #9192

Merged
merged 1 commit into from
Aug 1, 2018

Conversation

DSchalla
Copy link
Member

Summary

This PR implements CSRF token which are being attached to users sessions. The CSRF tokens are rolled out and can be enforced as alternative to XHR checks in the plugin request system. The plugin API was extended to allow a simple implementation in the plugins code base.

The PR lays the foundation for the further roll out in the MM core.

Ticket Link

https://mattermost.atlassian.net/browse/MM-10346

Checklist

  • Added or updated unit tests (required for all new features)
  • Touches critical sections of the codebase (auth, upgrade, etc.)

@DSchalla DSchalla force-pushed the plugin_csrftoken branch 2 times, most recently from f426725 to fc53647 Compare July 30, 2018 22:16
@jasonblais jasonblais added the 2: Dev Review Requires review by a developer label Jul 30, 2018
Copy link
Member

@crspeller crspeller left a comment

Choose a reason for hiding this comment

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

Looks good! Just a couple comments.

return nil, err
}

session.Token = ""
Copy link
Member

Choose a reason for hiding this comment

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

Lets give the plugins access to the session token.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

session, err := a.GetSession(token)
csrfCheck := true

if err == nil && r.Method != "GET" && r.Header.Get(model.HEADER_REQUESTED_WITH) != model.HEADER_REQUESTED_WITH_XML {
Copy link
Member

Choose a reason for hiding this comment

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

We check the header for GET requests as well on the server. Let's match that behavior. (But don't check the token for GET requests.)

Copy link
Member

Choose a reason for hiding this comment

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

Never mind this is handled upstream.

@DSchalla
Copy link
Member Author

Thanks for the review @crspeller!

Copy link
Member

@lieut-data lieut-data left a comment

Choose a reason for hiding this comment

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

Nice work, @DSchalla! A few comments below.

Also, @jwilander and @crspeller, it worries me that our Props fields have fixed lengths: 1000 in this case of the Session object. Have we run into issues before where new props (+ encoding overhead) overran this buffer size? Perhaps without being directly detected?

model/session.go Outdated
return ""
}

val, ok := me.Props["csrf"]
Copy link
Member

Choose a reason for hiding this comment

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

Props appears to be a map[string]string, so you can just return me.Props["csrf"] directly here and rely on the nil value of "".

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed - ty!

token := s.GetCSRF()

if token != "" {
t.Errorf("Expected empty, got %s", token)
Copy link
Member

Choose a reason for hiding this comment

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

nit: Might consider just using assert.Empty and assert.NotEqual below for simplicity.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good call, thanks!

@@ -126,7 +126,7 @@ func (a *App) GetUserForLogin(id, loginId string) (*model.User, *model.AppError)

func (a *App) DoLogin(w http.ResponseWriter, r *http.Request, user *model.User, deviceId string) (*model.Session, *model.AppError) {
session := &model.Session{UserId: user.Id, Roles: user.GetRawRoles(), DeviceId: deviceId, IsOAuth: false}

session.GenerateCSRF()
Copy link
Member

Choose a reason for hiding this comment

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

How do we decide whether or not to generate a CSRF token? For example, there's createSessionForUserAccessToken and oAuth's newSession -- should we be generating tokens in those cases as well?

Copy link
Member Author

@DSchalla DSchalla Aug 1, 2018

Choose a reason for hiding this comment

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

Good call, I added it for now to oauth and user access tokens as well to have all cases covered. For user access tokens it could be discussed for sure.

plugin/api.go Outdated
// GetSession returns the session object for the Session ID
GetSession(sessionId string) (*model.Session, *model.AppError)

// GetCSRF returns the CSRF token for the session Id
Copy link
Member

Choose a reason for hiding this comment

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

Is GetCSRF strictly required, given one can call GetCSRF on the result of GetSession above? Hoping we can keep the plugin API lean, and introduce helpers as needed (e.g. Session.GetCSRF could check if the pointer receiver is nil and return "" directly).

Copy link
Member Author

@DSchalla DSchalla Aug 1, 2018

Choose a reason for hiding this comment

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

The idea was to provide a as convenient integration as possible for the plugin developer, it's certainly not much more than a helper. That way you can get the CSRF token directly via the session id without fetching the session object itself.

@@ -7,4 +7,13 @@ package plugin
//
// It is currently a placeholder while the implementation details are sorted out.
type Context struct {
SessionId string
Copy link
Member

@lieut-data lieut-data Aug 1, 2018

Choose a reason for hiding this comment

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

I seem to recall @crspeller mentioning something about wanting to be able to change the wire encoding without breaking backwards compatibility. I'm not sure how that actually works, in practice, since the plugin will have compiled with this definition of Context and would need the field to be deserialized onto it /somehow/. Does that make the accessors below redundant, given it's a public field anyway?

Copy link
Member Author

@DSchalla DSchalla Aug 1, 2018

Choose a reason for hiding this comment

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

I think the idea was to be able to make Context later on an interface without a lot of interface adaption, which won't work with fields. I am not 100% sure though, any comments @crspeller? Making the struct field private won't work because of the encoding for gob.

Copy link
Member

Choose a reason for hiding this comment

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

Ya, now that I think about this, I don't think we are going to be able to accomplish that. I think we can just remove the unnecessary helpers.

Copy link
Member Author

Choose a reason for hiding this comment

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

Helpers got removed in the latest version.

@DSchalla DSchalla force-pushed the plugin_csrftoken branch 4 times, most recently from addc58c to 7340cfd Compare August 1, 2018 19:48
Copy link
Member

@lieut-data lieut-data left a comment

Choose a reason for hiding this comment

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

I hadn't caught up on the CSRF discussion on pre-release, so some of this might already be answered, but I threw a few questions to clarify my own understanding of the new restrictions.

Since Christopher's reviewed this code, I feel comfortable approving it, but welcome the continued discussion :)

token = cookie.Value
} else {
token = r.URL.Query().Get("access_token")
}

r.Header.Del("Mattermost-User-Id")
if token != "" {
if session, err := a.GetSession(token); session != nil && err == nil {
session, err := a.GetSession(token)
csrfCheck := true
Copy link
Member

Choose a reason for hiding this comment

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

nit: csrfCheck read to me as check CSRF not csrfCheckPassed or some such.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed, good point.

@@ -38,22 +40,42 @@ func (a *App) ServePluginRequest(w http.ResponseWriter, r *http.Request) {

func (a *App) servePluginRequest(w http.ResponseWriter, r *http.Request, handler func(*plugin.Context, http.ResponseWriter, *http.Request)) {
token := ""
context := &plugin.Context{}

authHeader := r.Header.Get(model.HEADER_AUTH)
if strings.HasPrefix(strings.ToUpper(authHeader), model.HEADER_BEARER+" ") {
Copy link
Member

Choose a reason for hiding this comment

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

This block of code interests me. I'm not sure why I missed it in the first round of reviews, but just some clarifying questions:

  • Will I now be prohibited from making authenticated requests through a plugin's API unless I manually send the HEADER_REQUESTED_WITH header with the requisite value? I think the webapp does this automatically for XMLHttpRequests, but what if I'm using curl/http to hit the plugin's API, or integrating with another application that interfaces with the API? (This might be by design and/or a requirement of CSRF protection, but just want to make sure.)
  • What if my plugin's API endpoint isn't parsing forms, but accepts JSON? How do I POST a JSON payload to my plugin endpoint /and/ set the csrf token in the body as a form value /and/ still have an authenticated request? I guess this boils down to the previous question requiring me to set this special header? Is it a standard header, or something custom we've adopted?
  • It looks like we support getting an access_token from the query string. Ignoring any security issues with that, I guess this happens to work because we don't issue access tokens corresponding csrf tokens. Of course, someone could pass their current session's token /as/ an access token of sorts, but I suppose we don't support that.

Copy link
Member Author

Choose a reason for hiding this comment

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

  • If you don't have the XMLHttpRequest header set you will be required to send a CSRF Token, that should not be the target when cookie authentication is used and was planned for a migration period, since that was the previous behavior. In the long-term I'd like to enforce CSRF tokens for all requests that were made with cookie auth.
  • You can send a csrf token via GET parameters as well, we could add a header option optionally. The url query parameter is supported ootb by FormValue in Go, the header would be an additional code enhancement.
  • That code snippet isn't from me, but according to my understanding access_token == session token != csrf token - Hope that helps?

Important: I added a new check that the CSRF enforcement is only applied for cookie authentication after our chat in pre-release. FYI @crspeller

deleted test config

fix test config

Dont wipe the session token for plugins

Simplified Tokens; Generate CSRF for other sessions

Remove CSRF from Access Token; Remove Getter/Setter from Context

fix removed setter

remove getcsrf helper from plugin api

enforce csrf only for cookie auth
@DSchalla
Copy link
Member Author

DSchalla commented Aug 1, 2018

I think we got now everything covered, thanks to both of you @lieut-data and @crspeller for the quick review! Looking forward to release the first alpha of Claptrap, with this PR being the last blocker for the remaining features. 🎉

@crspeller
Copy link
Member

@DSchalla Looking forward to the first release of Claptrap!

@crspeller crspeller merged commit 2936dc8 into mattermost:master Aug 1, 2018
@lindalumitchell lindalumitchell added the Tests/Not Needed New release tests not required label Aug 2, 2018
@amyblais amyblais added Changelog/Not Needed Does not require a changelog entry Docs/Not Needed Does not require documentation labels Aug 24, 2018
@lindy65 lindy65 removed the 2: Dev Review Requires review by a developer label Feb 20, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Changelog/Not Needed Does not require a changelog entry Docs/Not Needed Does not require documentation Tests/Not Needed New release tests not required
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants