Skip to content
Draft
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
3 changes: 2 additions & 1 deletion enterprise/coderd/aimodelprices.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ func (api *API) upsertAIModelPrices(rw http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
httpapi.RecordRequestBodyLimit(ctx, codersdk.MaxAIModelPricesBytes)
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
Message: "Request body too large.",
Detail: err.Error(),
Detail: fmt.Sprintf("Maximum request body size is %d bytes.", codersdk.MaxAIModelPricesBytes),
})
return
}
Expand Down
36 changes: 27 additions & 9 deletions enterprise/coderd/scim/scim.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
scimErrors "github.com/elimity-com/scim/errors"
"github.com/elimity-com/scim/optional"
"github.com/elimity-com/scim/schema"
"github.com/go-chi/chi/v5"

"cdr.dev/slog/v3"
agpl "github.com/coder/coder/v2/coderd"
Expand Down Expand Up @@ -109,8 +110,14 @@ func (s *Handler) authMiddleware(next http.Handler) http.Handler {
})
}

func (s *Handler) Handler() http.Handler {
return s.authMiddleware(s.srv)
// Handler returns the SCIM 2.0 handler. Middleware passed as inner runs after
// the SCIM API key check and before the SCIM server, so work that a caller can
// trigger without authenticating stays out of reach of an unauthenticated
// caller. The size bound on the request body is mounted this way: buffering a
// body for a caller who is about to be rejected at the API key check is work an
// unauthenticated caller should not be able to cause.
func (s *Handler) Handler(inner ...func(http.Handler) http.Handler) http.Handler {
return s.authMiddleware(chi.Chain(inner...).Handler(s.srv))
}

func (s *Handler) verifyAuthHeader(r *http.Request) bool {
Expand All @@ -125,14 +132,25 @@ func (s *Handler) verifyAuthHeader(r *http.Request) bool {
return len(s.opts.SCIMAPIKey) != 0 && subtle.ConstantTimeCompare(hdr, s.opts.SCIMAPIKey) == 1
}

func scimUnauthorized(rw http.ResponseWriter) {
// WriteError writes an RFC 7644 section 3.12 error response, per
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.12. SCIM providers
// parse that shape, so a codersdk.Response here would be a protocol violation
// even with the right status.
//
// RFC 7644 expresses status as a JSON string, which is what ScimError marshals.
// The legacy implementation's library writes it as a number, so the two paths
// differ on that field; this is the compliant form and is not the one to change.
func WriteError(rw http.ResponseWriter, status int, detail string) {
rw.Header().Set("Content-Type", "application/scim+json")
rw.WriteHeader(http.StatusUnauthorized)
// scim error spec:
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.12
rw.WriteHeader(status)
_ = json.NewEncoder(rw).Encode(scimErrors.ScimError{
ScimType: "", // No scimType exists for unauthorized errors.
Detail: "invalid authorization",
Status: http.StatusUnauthorized,
// RFC 7644 defines scimType keywords only for 400 responses.
ScimType: "",
Detail: detail,
Status: status,
})
}

func scimUnauthorized(rw http.ResponseWriter) {
WriteError(rw, http.StatusUnauthorized, "invalid authorization")
}
85 changes: 85 additions & 0 deletions enterprise/coderd/scim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"

"github.com/imulab/go-scim/pkg/v2/handlerutil"
Expand All @@ -16,6 +18,7 @@ import (

"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
Expand Down Expand Up @@ -719,3 +722,85 @@ func TestLegacyScimError(t *testing.T) {
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
}

// TestScimRequestBodyTooLarge covers both SCIM implementations. Neither decodes
// through httpapi.Read, so neither inherits its body limit: the legacy handler
// decodes r.Body directly, and the SCIM 2.0 library reads the whole body into
// memory and discards the read error on PUT and PATCH. Both are bounded by the
// scim route's own middleware, which must report the rejection in SCIM's error
// shape rather than as a codersdk.Response.
//
// The bound buffers, so where it sits in the chain is part of what is asserted:
// an authenticated caller gets 413, and an unauthenticated one gets 401 without
// the body being buffered on its behalf.
//
//nolint:gocritic // SCIM authenticates via a special header and bypasses internal RBAC.
func TestScimRequestBodyTooLarge(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
legacy bool
}{
{name: "SCIM2", legacy: false},
{name: "Legacy", legacy: true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)

scimAPIKey := []byte("hi")
client, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
SCIMAPIKey: scimAPIKey,
UseLegacySCIM: tc.legacy,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{codersdk.FeatureSCIM: 1},
},
})

// Only just over the limit. The server stops reading once the limit
// trips, so a large unread remainder risks the client observing a
// connection reset instead of the response.
body := fmt.Sprintf(`{"userName":%q}`, strings.Repeat("a", httpapi.DefaultMaxRequestBodyBytes))
require.Greater(t, len(body), httpapi.DefaultMaxRequestBodyBytes)

res, err := client.Request(ctx, http.MethodPost, "/scim/v2/Users",
strings.NewReader(body), setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()

require.Equal(t, http.StatusRequestEntityTooLarge, res.StatusCode)
require.Equal(t, "application/scim+json", res.Header.Get("Content-Type"))

// RFC 7644 section 3.12 error shape, not a codersdk.Response.
var scimErr struct {
Schemas []string `json:"schemas"`
Detail string `json:"detail"`
Status string `json:"status"`
}
require.NoError(t, json.NewDecoder(res.Body).Decode(&scimErr))
require.Equal(t, []string{"urn:ietf:params:scim:api:messages:2.0:Error"}, scimErr.Schemas)
require.Equal(t, "413", scimErr.Status)
require.Contains(t, scimErr.Detail, strconv.Itoa(httpapi.DefaultMaxRequestBodyBytes))

// The same body without the SCIM API key is answered 401. A 413 here
// would mean the body was buffered before the key was checked, which
// would let an unauthenticated caller spend the limit per request on
// a route that carries no rate limit.
//
// Served through the mounted handler rather than the client, because
// rejecting at the key check reads none of the body and a client
// still writing an oversized one observes a connection reset rather
// than the response.
unauthed := httptest.NewRequest(http.MethodPost, "/scim/v2/Users", strings.NewReader(body))
unauthed.Header.Set("Content-Type", "application/scim+json")
rec := httptest.NewRecorder()

api.AGPL.RootHandler.ServeHTTP(rec, unauthed)

require.Equal(t, http.StatusUnauthorized, rec.Code,
"response body: %s", rec.Body.String())
})
}
}
58 changes: 57 additions & 1 deletion enterprise/coderd/scimroutes.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package coderd

import (
"bytes"
"fmt"
"io"
"net/http"

"github.com/go-chi/chi/v5"
Expand All @@ -13,6 +16,50 @@ import (
"github.com/coder/coder/v2/enterprise/coderd/scim"
)

// scimMaxRequestBodyBytes bounds a SCIM request body. SCIM does not decode
// through httpapi.Read and so does not inherit its limit: the legacy handler
// decodes r.Body directly, and the SCIM 2.0 library calls io.ReadAll(r.Body) on
// every method that carries one, including the .search POST. The library's read
// happens before any error handling of its own could report it, so neither
// implementation would report an oversized body as such and the bound is
// enforced outside both handlers.
const scimMaxRequestBodyBytes = httpapi.DefaultMaxRequestBodyBytes

// scimLimitRequestBody rejects a request body over scimMaxRequestBodyBytes with
// a SCIM-shaped 413 and passes the buffered body to next. The limit is enforced
// on bytes read rather than on Content-Length, which is absent under chunked
// transfer encoding and caller-controlled otherwise.
//
// This buffers, so it is mounted after the SCIM API key check on both
// implementations. Mounting it ahead of authentication would let an
// unauthenticated caller spend scimMaxRequestBodyBytes per request on a path
// that has no rate limit, which is the exposure the bound exists to remove.
func scimLimitRequestBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.Body == nil {
next.ServeHTTP(rw, r)
return
}

// Reading a single byte past the limit is what separates an at-limit
// body from an oversized one.
body, err := io.ReadAll(io.LimitReader(r.Body, scimMaxRequestBodyBytes+1))
if err != nil {
scim.WriteError(rw, http.StatusBadRequest, "could not read request body")
return
}
if len(body) > scimMaxRequestBodyBytes {
httpapi.RecordRequestBodyLimit(r.Context(), scimMaxRequestBodyBytes)
scim.WriteError(rw, http.StatusRequestEntityTooLarge,
fmt.Sprintf("request body must not exceed %d bytes", scimMaxRequestBodyBytes))
return
}

r.Body = io.NopCloser(bytes.NewReader(body))
next.ServeHTTP(rw, r)
})
}

func (api *API) mountScimRoute(opt *Options, r chi.Router) error {
if len(opt.SCIMAPIKey) == 0 {
// Show a helpful 404 error. Because this is not under the /api/v2 routes,
Expand Down Expand Up @@ -42,9 +89,14 @@ func (api *API) mountScimRoute(opt *Options, r chi.Router) error {
SCIMAPIKey: opt.SCIMAPIKey,
Auditor: &api.AGPL.Auditor,
}
// The body bound runs after AuthMiddleware, which is a header
// comparison that never reads the body. A caller without the SCIM API
// key is rejected there having caused no read, and an authenticated
// caller still reaches the handler through the bound.
r.Mount("/v2", chi.Chain(
api.RequireFeatureMW(codersdk.FeatureSCIM),
legacySrv.AuthMiddleware,
scimLimitRequestBody,
).Handler(legacySrv.Handler()))
return nil
}
Expand All @@ -66,9 +118,13 @@ func (api *API) mountScimRoute(opt *Options, r chi.Router) error {
// internally. Chi's Route/Mount modifies its own routing context
// but not r.URL.Path, so we use http.StripPrefix to ensure the
// library sees paths like "/v2/Users" instead of "/scim/v2/Users".
//
// The body bound is passed to Handler rather than mounted here because this
// implementation authenticates inside its own handler. Handler runs it after
// the API key check, so an unauthenticated caller causes no read.
r.Mount("/", chi.Chain(
api.RequireFeatureMW(codersdk.FeatureSCIM),
middleware.StripPrefix("/scim"),
).Handler(scimSrv.Handler()))
).Handler(scimSrv.Handler(scimLimitRequestBody)))
return nil
}
109 changes: 109 additions & 0 deletions scripts/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,3 +558,112 @@ func codersdkResponseBodyDecode(m dsl.Matcher) {
).
Report("Use codersdk.ReadBodyAsJSON to decode typed API responses so non-JSON bodies produce a structured error. For responses that are intentionally not Coder API JSON, add a nolint:gocritic comment explaining why.")
}

// unboundedRequestBody reports an HTTP request body read without a size limit.
// Handlers should decode through httpapi.Read or httpapi.ReadLimit, which bound
// the body; reading r.Body directly bypasses that bound (CWE-770) and is
// reachable before authentication on endpoints that authenticate by body. The
// rule covers the coderd API only, since the agent, aibridge, and the load-test
// mocks serve separate ingress with their own limits.
//
// A correct http.MaxBytesReader wrap is textually indistinguishable from a
// missing one, so those sites are enumerated below rather than detected. Adding
// an entry asserts the site bounds its own body and answers 413; it is not a way
// to opt out of having a bound. Outbound requests are exempt for a different
// reason: this process wrote the body.
//
// The match list covers the ways a body is consumed today plus the ones a
// contributor would reach for next. ParseForm and ParseMultipartForm are here
// because net/http caps an unwrapped urlencoded body at its own 10 MiB
// maxFormSize, larger than the ceiling httpapi.Read carries, and defers to a
// *http.MaxBytesReader when it finds one.
//
// Binding a body to a local first, as in `body := r.Body`, defeats the match:
// ruleguard compares selector expressions and does not follow aliases. The rule
// is a nudge toward the idiom, not a proof that no unbounded read exists.
//
//nolint:unused,deadcode,varnamelen
func unboundedRequestBody(m dsl.Matcher) {
m.Import("bufio")
m.Import("encoding/csv")
m.Import("encoding/json")
m.Import("encoding/xml")
m.Import("io")
m.Import("net/http")

m.Match(
`json.NewDecoder($r.Body).Decode($_)`,
`$_ := json.NewDecoder($r.Body)`,
`$_ = json.NewDecoder($r.Body)`,
`io.ReadAll($r.Body)`,
`io.Copy($_, $r.Body)`,
`bufio.NewReader($r.Body)`,
`bufio.NewScanner($r.Body)`,
`xml.NewDecoder($r.Body)`,
`csv.NewReader($r.Body)`,
`$r.ParseForm()`,
`$r.ParseMultipartForm($_)`,
).
Where(
(m["r"].Type.Is("*http.Request") || m["r"].Type.Is("http.Request")) &&
// Scoped to the coderd HTTP API, where httpapi.Read is the
// idiom. The agent, aibridge, and the load-test mocks serve
// their own ingress with their own error shapes and their own
// limits, so httpapi.Read is not the remedy there.
m.File().PkgPath.Matches(`/coderd(/|$)`) &&
!m.File().Name.Matches(`_test\.go$`) &&
// Packages named for testing hold fake servers and test doubles
// rather than production ingress, and some of them live outside
// _test.go files so other packages can import them.
!m.File().PkgPath.Matches(`test$`) &&

// Ingress that installs its own bound and reports 413:
//
// httpapi.ReadLimit holds the wrap that every handler decoding
// through httpapi.Read inherits its bound from.
!(m.File().PkgPath.Matches(`/coderd/httpapi$`) &&
m.File().Name.Matches(`^httpapi\.go$`)) &&
// csp.go bounds CSP reports at 64 KiB. files.go bounds template
// archive uploads at 100 MiB and exp_chats.go bounds chat file
// uploads at 10 MiB; those two are binary uploads that no JSON
// limit should apply to.
//
// Anchored on /v2/coderd$ rather than /coderd$, which would also
// match enterprise/coderd and exempt files there that these
// entries say nothing about.
!(m.File().PkgPath.Matches(`/v2/coderd$`) &&
m.File().Name.Matches(`^(csp|files|exp_chats)\.go$`)) &&
// aimodelprices.go bounds an experimental JSON upload at 1 MiB,
// reading it whole because it decodes the same bytes twice to
// tell an absent price from a null one.
!(m.File().PkgPath.Matches(`/enterprise/coderd$`) &&
m.File().Name.Matches(`^aimodelprices\.go$`)) &&
// registration.go bounds its body at the default limit and
// reports 413 as an RFC 7591 error, which httpapi.Read cannot do.
!(m.File().PkgPath.Matches(`/coderd/oauth2provider$`) &&
m.File().Name.Matches(`^registration\.go$`)) &&
// oauth2.go installs the bound that the form parses on
// /oauth2/tokens and /oauth2/revoke read through, and reports 413
// as an RFC 6749 error. tokens.go and revoke.go parse the form
// again when client_id came from the query string, so the
// middleware had no reason to; both are reachable only under it
// and both translate its *http.MaxBytesError to the same 413.
!(m.File().PkgPath.Matches(`/coderd/httpmw$`) &&
m.File().Name.Matches(`^oauth2\.go$`)) &&
!(m.File().PkgPath.Matches(`/coderd/oauth2provider$`) &&
m.File().Name.Matches(`^(tokens|revoke)\.go$`)) &&
// The SCIM routes bound every body ahead of these handlers, in
// enterprise/coderd/scimroutes.go, because a SCIM rejection must
// be reported in SCIM's own error shape.
!(m.File().PkgPath.Matches(`/enterprise/coderd/legacyscim$`) &&
m.File().Name.Matches(`^legacyscim\.go$`)) &&

// Outbound requests, not ingress:
//
// openai_compat_patches.go is an http.RoundTripper rewriting a
// request body this process built.
!(m.File().PkgPath.Matches(`/coderd/x/chatd/chatprovider$`) &&
m.File().Name.Matches(`^openai_compat_patches\.go$`)),
).
Report("Read request bodies with httpapi.Read or httpapi.ReadLimit so the body is size-bounded. If this handler must read r.Body directly, bound it with http.MaxBytesReader, answer 413 when the bound trips, and add the file to the allowlist in unboundedRequestBody in scripts/rules.go. If this is an outbound request rather than ingress, add it to the outbound group instead.")
}
Loading