Skip to content

fix: stop leaking User into API handlers unless authorized #10172

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

Merged
merged 1 commit into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,6 @@ func New(options *Options) *API {
r.Get("/roles", api.assignableOrgRoles)
r.Route("/{user}", func(r chi.Router) {
r.Use(
httpmw.ExtractUserParam(options.Database),
httpmw.ExtractOrganizationMemberParam(options.Database),
)
r.Put("/roles", api.putMemberRoles)
Expand Down
36 changes: 32 additions & 4 deletions coderd/httpmw/organizationparam.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)
Expand All @@ -25,8 +26,8 @@ func OrganizationParam(r *http.Request) database.Organization {

// OrganizationMemberParam returns the organization membership that allowed the query
// from the ExtractOrganizationParam handler.
func OrganizationMemberParam(r *http.Request) database.OrganizationMember {
organizationMember, ok := r.Context().Value(organizationMemberParamContextKey{}).(database.OrganizationMember)
func OrganizationMemberParam(r *http.Request) OrganizationMember {
organizationMember, ok := r.Context().Value(organizationMemberParamContextKey{}).(OrganizationMember)
if !ok {
panic("developer error: organization member param middleware not provided")
}
Expand Down Expand Up @@ -62,14 +63,31 @@ func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler
}
}

// OrganizationMember is the database object plus the Username. Including the Username in this
// middleware is preferable to a join at the SQL layer so that we can keep the autogenerated
// database types as they are.
type OrganizationMember struct {
database.OrganizationMember
Username string
}

// ExtractOrganizationMemberParam grabs a user membership from the "organization" and "user" URL parameter.
// This middleware requires the ExtractUser and ExtractOrganization middleware higher in the stack
func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// We need to resolve the `{user}` URL parameter so that we can get the userID and
// username. We do this as SystemRestricted since the caller might have permission
// to access the OrganizationMember object, but *not* the User object. So, it is
// very important that we do not add the User object to the request context or otherwise
// leak it to the API handler.
// nolint:gocritic
user, ok := extractUserContext(dbauthz.AsSystemRestricted(ctx), db, rw, r)
if !ok {
return
}
organization := OrganizationParam(r)
user := UserParam(r)

organizationMember, err := db.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{
OrganizationID: organization.ID,
Expand All @@ -87,7 +105,17 @@ func ExtractOrganizationMemberParam(db database.Store) func(http.Handler) http.H
return
}

ctx = context.WithValue(ctx, organizationMemberParamContextKey{}, organizationMember)
ctx = context.WithValue(ctx, organizationMemberParamContextKey{}, OrganizationMember{
OrganizationMember: organizationMember,
// Here we're making one exception to the rule about not leaking data about the user
// to the API handler, which is to include the username. If the caller has permission
// to read the OrganizationMember, then we're explicitly saying here that they also
// have permission to see the member's username, which is itself uncontroversial.
//
// API handlers need this information for audit logging and returning the owner's
// username in response to creating a workspace.
Username: user.Username,
})
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
Expand Down
7 changes: 1 addition & 6 deletions coderd/httpmw/userparam.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"github.com/google/uuid"

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)
Expand Down Expand Up @@ -38,11 +37,7 @@ func ExtractUserParam(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// We need to call as SystemRestricted because this middleware is called from
// organizations/{organization}/members/{user}/ paths, and we need to allow
// org-admins to call these paths --- they might not have sitewide read permissions on users.
// nolint:gocritic
user, ok := extractUserContext(dbauthz.AsSystemRestricted(ctx), db, rw, r)
user, ok := extractUserContext(ctx, db, rw, r)
if !ok {
// response already handled
return
Expand Down
3 changes: 1 addition & 2 deletions coderd/members.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import (
func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
organization = httpmw.OrganizationParam(r)
member = httpmw.OrganizationMemberParam(r)
apiKey = httpmw.APIKey(r)
Expand All @@ -51,7 +50,7 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) {

updatedUser, err := api.updateOrganizationMemberRoles(ctx, database.UpdateMemberRolesParams{
GrantedRoles: params.Roles,
UserID: user.ID,
UserID: member.UserID,
OrgID: organization.ID,
})
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ func TestDeleteUser(t *testing.T) {
err := client.DeleteUser(context.Background(), firstUser.UserID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})
t.Run("HasWorkspaces", func(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -930,7 +930,7 @@ func TestGrantSiteRoles(t *testing.T) {
AssignToUser: first.UserID.String(),
Roles: []string{},
Error: true,
StatusCode: http.StatusForbidden,
StatusCode: http.StatusBadRequest,
},
{
// Cannot update your own roles
Expand Down
14 changes: 7 additions & 7 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,9 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
organization = httpmw.OrganizationParam(r)
apiKey = httpmw.APIKey(r)
auditor = api.Auditor.Load()
user = httpmw.UserParam(r)
member = httpmw.OrganizationMemberParam(r)
workspaceResourceInfo = audit.AdditionalFields{
WorkspaceOwner: user.Username,
WorkspaceOwner: member.Username,
}
)

Expand All @@ -339,7 +339,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req

// Do this upfront to save work.
if !api.Authorize(r, rbac.ActionCreate,
rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(user.ID.String())) {
rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(member.UserID.String())) {
httpapi.ResourceNotFound(rw)
return
}
Expand Down Expand Up @@ -456,7 +456,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
// read other workspaces. Ideally we check the error on create and look for
// a postgres conflict error.
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: user.ID,
OwnerID: member.UserID,
Name: createWorkspace.Name,
})
if err == nil {
Expand Down Expand Up @@ -489,7 +489,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
ID: uuid.New(),
CreatedAt: now,
UpdatedAt: now,
OwnerID: user.ID,
OwnerID: member.UserID,
OrganizationID: template.OrganizationID,
TemplateID: template.ID,
Name: createWorkspace.Name,
Expand Down Expand Up @@ -555,7 +555,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
ProvisionerJob: *provisionerJob,
QueuePosition: 0,
},
user.Username,
member.Username,
[]database.WorkspaceResource{},
[]database.WorkspaceResourceMetadatum{},
[]database.WorkspaceAgent{},
Expand All @@ -576,7 +576,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
workspace,
apiBuild,
template,
user.Username,
member.Username,
))
}

Expand Down