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
57 changes: 57 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions coderd/httpapi/httpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,17 @@ func WriteOAuth2Error(ctx context.Context, rw http.ResponseWriter, status int, e
ErrorDescription: description,
})
}

// WriteOAuth2RequestTooLarge reports a request body over limit as an RFC 6749
// error, for the OAuth2 endpoints that read a form body rather than decoding
// through Read. RFC 6749 defines no error code for a transport rejection, so
// invalid_request is the closest compliant framing.
//
// The limit is the one carried by the *http.MaxBytesError that tripped, so a
// caller of this function reports the bound that actually applied rather than
// the one it assumes applied.
func WriteOAuth2RequestTooLarge(ctx context.Context, rw http.ResponseWriter, limit int64) {
RecordRequestBodyLimit(ctx, limit)
WriteOAuth2Error(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.OAuth2ErrorCodeInvalidRequest,
fmt.Sprintf("Maximum request body size is %d bytes.", limit))
}
38 changes: 37 additions & 1 deletion coderd/httpmw/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package httpmw

import (
"context"
"errors"
"fmt"
"net"
"net/http"
Expand Down Expand Up @@ -374,6 +375,7 @@ type errorWriter interface {
writeMissingClientID(ctx context.Context, rw http.ResponseWriter)
writeInvalidClientID(ctx context.Context, rw http.ResponseWriter, err error)
writeClientNotFound(ctx context.Context, rw http.ResponseWriter)
writeRequestTooLarge(ctx context.Context, rw http.ResponseWriter, limit int64)
}

// codersdkErrorWriter writes standard codersdk errors for general API endpoints
Expand Down Expand Up @@ -402,6 +404,14 @@ func (*codersdkErrorWriter) writeClientNotFound(ctx context.Context, rw http.Res
})
}

func (*codersdkErrorWriter) writeRequestTooLarge(ctx context.Context, rw http.ResponseWriter, limit int64) {
httpapi.RecordRequestBodyLimit(ctx, limit)
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
Message: "Request body too large.",
Detail: fmt.Sprintf("Maximum request body size is %d bytes.", limit),
})
}

// oauth2ErrorWriter writes OAuth2-compliant errors for OAuth2 endpoints
type oauth2ErrorWriter struct{}

Expand All @@ -417,13 +427,29 @@ func (*oauth2ErrorWriter) writeClientNotFound(ctx context.Context, rw http.Respo
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
}

func (*oauth2ErrorWriter) writeRequestTooLarge(ctx context.Context, rw http.ResponseWriter, limit int64) {
httpapi.WriteOAuth2RequestTooLarge(ctx, rw, limit)
}

// extractOAuth2ProviderAppBase is the internal implementation that uses the strategy pattern
// instead of a control flag to handle different error formats.
func extractOAuth2ProviderAppBase(db database.Store, errWriter errorWriter) 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()

// POST /oauth2/tokens and POST /oauth2/revoke authenticate the
// client from the request body, so they cannot require an API key
// and the body below is read before any authorization decision.
// Bound it here, ahead of every reader: r.ParseForm below, and the
// handlers downstream, all read through this wrap.
//
// net/http caps an unwrapped urlencoded body at its own 10 MiB
// maxFormSize, which is 2.5x the ceiling every other endpoint
// carries. It defers to a *http.MaxBytesReader when it finds one,
// so installing one replaces that cap with this one.
r.Body = http.MaxBytesReader(rw, r.Body, httpapi.DefaultMaxRequestBodyBytes)

// App can come from a URL param, query param, or form value.
paramID := "app"
var appID uuid.UUID
Expand All @@ -441,9 +467,19 @@ func extractOAuth2ProviderAppBase(db database.Store, errWriter errorWriter) func
paramAppID := r.URL.Query().Get("client_id")
if paramAppID == "" {
// Check the form params!
if r.ParseForm() == nil {
err := r.ParseForm()
if maxBytesErr, ok := errors.AsType[*http.MaxBytesError](err); ok {
// An oversized body is a size failure and is reported as
// one. Falling through would report the client_id this
// body may well have carried as missing.
errWriter.writeRequestTooLarge(ctx, rw, maxBytesErr.Limit)
return
}
if err == nil {
paramAppID = r.Form.Get("client_id")
}
// Any other parse failure is not fatal here: the client_id
// may still arrive through HTTP Basic below.
}
if paramAppID == "" {
// RFC 6749 §2.3.1: confidential clients may authenticate via
Expand Down
49 changes: 49 additions & 0 deletions coderd/httpmw/oauth2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@ package httpmw_test

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"

"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
Expand Down Expand Up @@ -369,3 +375,46 @@ func (p *exchangeAssertingProvider) Exchange(_ context.Context, _ string, opts .
func (*exchangeAssertingProvider) TokenSource(_ context.Context, _ *oauth2.Token) oauth2.TokenSource {
return nil
}

// TestExtractOAuth2ProviderAppBodyTooLarge covers the pre-authentication body
// read on POST /oauth2/tokens and POST /oauth2/revoke. Those routes authenticate
// the client from the request body, so they carry no API key middleware and the
// form parse below runs before any authorization decision.
//
// net/http would otherwise cap the body at its own 10 MiB maxFormSize, which is
// larger than the ceiling every other endpoint carries. The rejection is
// asserted in the RFC 6749 error shape because a client that parses that shape
// cannot read a codersdk.Response, so the status alone is not sufficient.
func TestExtractOAuth2ProviderAppBodyTooLarge(t *testing.T) {
t.Parallel()

db, _ := dbtestutil.NewDB(t)

// Padding a valid form past the limit leaves size as the only reason to
// reject the request. client_id is present in the body, so a fallthrough to
// the missing-client_id path would be observable as a 400.
oversized := "client_id=" + uuid.NewString() + "&pad=" +
strings.Repeat("a", httpapi.DefaultMaxRequestBodyBytes)
require.Greater(t, len(oversized), httpapi.DefaultMaxRequestBodyBytes)

var nextCalled bool
handler := httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(db)(
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
nextCalled = true
}),
)

r := httptest.NewRequest(http.MethodPost, "/oauth2/tokens", strings.NewReader(oversized))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rw := httptest.NewRecorder()

handler.ServeHTTP(rw, r)

require.Equal(t, http.StatusRequestEntityTooLarge, rw.Code)
require.False(t, nextCalled, "handler ran on an oversized body")

var errResp codersdk.OAuth2Error
require.NoError(t, json.Unmarshal(rw.Body.Bytes(), &errResp))
require.Equal(t, codersdk.OAuth2ErrorCodeInvalidRequest, errResp.Error)
require.Contains(t, errResp.ErrorDescription, strconv.Itoa(httpapi.DefaultMaxRequestBodyBytes))
}
2 changes: 2 additions & 0 deletions coderd/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
// @Param refresh_token formData string false "Refresh token, required if grant_type=refresh_token"
// @Param grant_type formData codersdk.OAuth2ProviderGrantType true "Grant type"
// @Success 200 {object} oauth2.Token
// @Failure 413 {object} codersdk.OAuth2Error
// @Router /oauth2/tokens [post]
func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc {
return oauth2provider.Tokens(api.Database, api.DeploymentValues.Sessions)
Expand All @@ -176,6 +177,7 @@ func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
// @Param token formData string true "The token to revoke"
// @Param token_type_hint formData string false "Hint about token type (access_token or refresh_token)"
// @Success 200 "Token successfully revoked"
// @Failure 413 {object} codersdk.OAuth2Error
// @Router /oauth2/revoke [post]
func (api *API) revokeOAuth2Token() http.HandlerFunc {
return oauth2provider.RevokeToken(api.Database, api.Logger)
Expand Down
Loading
Loading