mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
b776a14b46
## Summary Harden the OAuth2 provider with multiple security fixes addressing `coder/security#121` (CSRF session takeover) and converge on OAuth 2.1 compliance. ### Security Fixes | Fix | Description | Commits | |-----|-------------|---------| | **CSRF on `/oauth2/authorize`** | Enforce CSRF protection on the authorize endpoint POST (consent form submission) | `ba7d646`, `b94a64e` | | **Clickjacking: `frame-ancestors` CSP** | Prevent consent page from being iframed (`Content-Security-Policy: frame-ancestors 'none'` + `X-Frame-Options: DENY`) | `597aeb2` | | **Exact redirect URI matching** | Changed from prefix matching to full string exact matching per OAuth 2.1 §4.1.2.1 | `73d64b1`, `93897f1` | | **Store & verify `redirect_uri`** | Store redirect_uri with auth code in DB, verify at token exchange matches exactly (RFC 6749 §4.1.3) | `50569b9`, `d7ca315` | | **Mandatory PKCE** | Require `code_challenge` at authorization (for `response_type=code`) + unconditional `code_verifier` verification at token exchange | `d7ca315`, `1cda1a9` | | **Reject implicit grant** | `response_type=token` now returns `unsupported_response_type` error page (OAuth 2.1 removes implicit flow) | `d7ca315`, `91b8863` | ### Changes by File **`coderd/httpmw/csrf.go`** — Extended the CSRF `ExemptFunc` to enforce CSRF on `/oauth2/authorize` in addition to `/api` routes. The consent form POST is now CSRF-protected to prevent cross-site authorization code theft. **`site/site.go`** — Added `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY` headers to `RenderOAuthAllowPage` (consent page only — does not affect the SPA/global CSP used by AI tasks). **`coderd/httpapi/queryparams.go`** — Changed `RedirectURL` from prefix matching (`strings.HasPrefix(v.Path, base.Path)`) to full URI exact matching (`v.String() != base.String()`), comparing scheme, host, path, and query. **`coderd/oauth2provider/authorize.go`** — Added PKCE enforcement: `code_challenge` is required when `response_type=code` (via a conditional check, not `RequiredNotEmpty`, so `response_type=token` can reach the explicit rejection path). `ShowAuthorizePage` (GET) validates `response_type` before rendering and returns a 400 error page for unsupported types. `ProcessAuthorize` (POST) stores the `redirect_uri` with the auth code when explicitly provided. **`coderd/oauth2provider/tokens.go`** — PKCE verification is now unconditional (not gated on `code_challenge` being present in DB). If the stored code has a `redirect_uri`, the token endpoint verifies it matches exactly — mismatch returns `errBadCode` → `invalid_grant`. Missing `code_verifier` returns `invalid_grant`. **`codersdk/oauth2.go`** — `OAuth2ProviderResponseTypeToken` constant and `Valid()` acceptance are **kept** so the authorize handler can parse `response_type=token` and return the proper `unsupported_response_type` error rather than failing at parameter validation. **`coderd/database/migrations/000421_*`** — Added `redirect_uri text` column to `oauth2_provider_app_codes`. ### Design Decisions **`state` parameter remains optional** — The plan initially required `state` via `RequiredNotEmpty`, but this was reverted in `376a753` to avoid breaking existing clients. The `state` is still hashed and stored when provided (via `state_hash` column), securing clients that opt in. **`response_type=token` kept in `Valid()`** — Removing it from `Valid()` would cause the parameter parser to reject the request before the authorize handler can return the proper `unsupported_response_type` error. The constant is kept for correct error handling flow. **CSP scoped to consent page only** — `frame-ancestors 'none'` is set only on the OAuth consent page renderer, not globally. The SPA/global CSP was previously changed to allow framing for AI tasks ([#18102](https://github.com/coder/coder/pull/18102)); this change does not regress that. ### Out of Scope (follow-up PRs) - Bearer tokens in query strings (needs internal caller audit) - Scope enforcement on OAuth2 tokens - Rate limiting on dynamic client registration --- <details> <summary>📋 Implementation Plan</summary> # Plan: Harden OAuth2 Provider — Security Fixes + OAuth 2.1 Compliance ## Context & Why Security issue `coder/security#121` reports a critical session takeover via CSRF on the OAuth2 provider. This plan covers all remaining security fixes from that issue **plus** convergence on OAuth 2.1 requirements. The goal is a single PR that closes all actionable gaps. ## Current State (already committed on branch `csrf-sjx1`) | Fix | Status | Commits | |-----|--------|---------| | Fix 1: CSRF on `/oauth2/authorize` | ✅ Done | `ba7d646`, `b94a64e` | | CSRF token in consent form HTML | ✅ Done | `b94a64e` | | `state_hash` column + storage | ✅ Done (hash stored, but state still optional) | `9167d83`, `b94a64e` | | Tests for CSRF + state hash | ✅ Done | `e4119b5` | ## Remaining Work ### ~~Fix 2 — Require `state` parameter~~ (DROPPED) > **Decision:** Do not enforce `state` as required. The `state` parameter is still hashed and stored when provided (via `hashOAuth2State` / `state_hash` column from prior commits), but clients are not forced to supply it. This avoids breaking existing integrations that omit state. **Rollback:** Remove `"state"` from the `RequiredNotEmpty` call in `coderd/oauth2provider/authorize.go:42`: ```go // BEFORE (current on branch) p.RequiredNotEmpty("response_type", "client_id", "state", "code_challenge") // AFTER p.RequiredNotEmpty("response_type", "client_id", "code_challenge") ``` No test changes needed — tests already pass `state` voluntarily. ### Fix 4 — Exact redirect URI matching Currently `coderd/httpapi/queryparams.go:233` uses prefix matching: ```go // CURRENT — prefix match if v.Host != base.Host || !strings.HasPrefix(v.Path, base.Path) { ``` OAuth 2.1 requires **exact string matching**. Change to: ```go // AFTER — exact match (OAuth 2.1 §4.1.2.1) if v.Host != base.Host || v.Path != base.Path { ``` **File: `coderd/httpapi/queryparams.go` — `RedirectURL` method** Also update the error message from "must be a subset of" to "must exactly match". **Additionally**, store `redirect_uri` with the auth code and verify at the token endpoint (RFC 6749 §4.1.3): 1. **New migration** (same migration file or a new `000421`): Add `redirect_uri text` column to `oauth2_provider_app_codes` 2. **Update INSERT query** in `coderd/database/queries/oauth2.sql` to include `redirect_uri` 3. **`coderd/oauth2provider/authorize.go`**: Store `params.redirectURL.String()` when inserting the code 4. **`coderd/oauth2provider/tokens.go`**: After retrieving the code from DB, verify that `redirect_uri` from the token request matches the stored value exactly. Currently `tokens.go:103` calls `p.RedirectURL(vals, callbackURL, "redirect_uri")` for prefix validation only — it must compare against the stored redirect_uri from the code, not just the app's callback URL. <details> <summary>Why both exact match AND store+verify?</summary> Exact matching at the authorize endpoint prevents open redirectors (attacker can't use a sub-path). Storing and verifying at the token endpoint prevents code injection — an attacker who steals a code can't exchange it with a different redirect_uri than was originally authorized. This is required by RFC 6749 §4.1.3 and OAuth 2.1. </details> ### Fix 7 — `frame-ancestors` CSP on consent page The consent page can be iframed by a workspace app (same-site), which is the attack vector. Add a `Content-Security-Policy` header to prevent framing. **File: `site/site.go` — `RenderOAuthAllowPage` function (~line 731)** Before writing the response, add: ```go func RenderOAuthAllowPage(rw http.ResponseWriter, r *http.Request, data RenderOAuthAllowData) { rw.Header().Set("Content-Type", "text/html; charset=utf-8") // Prevent the consent page from being framed to mitigate // clickjacking attacks (coder/security#121). rw.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") rw.Header().Set("X-Frame-Options", "DENY") ... ``` Both headers for defense-in-depth (CSP for modern browsers, X-Frame-Options for legacy). ### OAuth 2.1 — Mandatory PKCE Currently PKCE is checked only when `code_challenge` was provided during authorization (`tokens.go:258`): ```go // CURRENT — conditional check if dbCode.CodeChallenge.Valid && dbCode.CodeChallenge.String != "" { // verify PKCE } ``` OAuth 2.1 requires PKCE for ALL authorization code flows. Change to: **File: `coderd/oauth2provider/authorize.go`** — Add `"code_challenge"` to required params: ```go p.RequiredNotEmpty("response_type", "client_id", "code_challenge") ``` **File: `coderd/oauth2provider/tokens.go:257-265`** — Make PKCE verification unconditional: ```go // AFTER — PKCE always required (OAuth 2.1) if req.CodeVerifier == "" { return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } if !dbCode.CodeChallenge.Valid || dbCode.CodeChallenge.String == "" { // Code was issued without a challenge — should not happen // with the authorize endpoint enforcement, but defend in // depth. return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } if !VerifyPKCE(dbCode.CodeChallenge.String, req.CodeVerifier) { return codersdk.OAuth2TokenResponse{}, errInvalidPKCE } ``` **File: `codersdk/oauth2.go`** — Remove `OAuth2ProviderResponseTypeToken` from the enum or reject it explicitly in the authorize handler. Currently it's defined at line 216 but the handler ignores `response_type` and always issues a code. We should either: - (a) Remove the `"token"` variant from the enum and reject it with `unsupported_response_type`, OR - (b) Add an explicit check in `ProcessAuthorize` that rejects `response_type=token` Option (b) is simpler and more backwards-compatible: ```go // In ProcessAuthorize, after extracting params: if params.responseType != codersdk.OAuth2ProviderResponseTypeCode { httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedResponseType, "Only response_type=code is supported") return } ``` ### OAuth 2.1 — Bearer tokens in query strings `coderd/httpmw/apikey.go:743` accepts `access_token` from URL query parameters. OAuth 2.1 prohibits this. However, this may be used internally (e.g., workspace apps, DERP). Need to audit callers before removing. **Approach:** This is a larger change with potential breakage. Mark as a **separate follow-up issue** rather than including in this PR. Document the finding. ### OAuth 2.1 — Removed flows ✅ **Already compliant.** `tokens.go` only supports `authorization_code` and `refresh_token` grant types. The implicit grant (`response_type=token`) will be explicitly rejected per the PKCE section above. ### OAuth 2.1 — Refresh token rotation ✅ **Already compliant.** `tokens.go:442` deletes the old API key when a refresh token is used. ## Migration Plan All DB changes can go in a single new migration (or extend 000420 if the branch is rebased before merge). Columns to add: - `redirect_uri text` on `oauth2_provider_app_codes` The `state_hash` column is already added by migration 000420. ## Implementation Order 1. **Fix 7** — CSP headers on consent page (isolated, no deps) 2. ~~**Fix 2** — Require `state` parameter~~ (DROPPED — state stays optional) 3. **Fix 4** — Exact redirect URI matching + store/verify redirect_uri 4. **PKCE mandatory** — Require `code_challenge` + reject `response_type=token` 5. **Rollback** — Remove `"state"` from `RequiredNotEmpty` in `authorize.go` 6. **Tests** — Update/add tests for all changes 7. **`make gen`** after DB changes ## Out of Scope (separate PRs) - Bearer tokens in query strings (needs internal caller audit) - Scope enforcement on OAuth2 tokens - Rate limiting / quota on dynamic client registration </details> --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh`_
467 lines
16 KiB
Go
467 lines
16 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// QueryParamParser is a helper for parsing all query params and gathering all
|
|
// errors in 1 sweep. This means all invalid fields are returned at once,
|
|
// rather than only returning the first error
|
|
type QueryParamParser struct {
|
|
// Errors is the set of errors to return via the API. If the length
|
|
// of this set is 0, there are no errors!.
|
|
Errors []codersdk.ValidationError
|
|
// Parsed is a map of all query params that were parsed. This is useful
|
|
// for checking if extra query params were passed in.
|
|
Parsed map[string]bool
|
|
// RequiredNotEmptyParams is a map of all query params that are required. This is useful
|
|
// for forcing a value to be provided.
|
|
RequiredNotEmptyParams map[string]bool
|
|
}
|
|
|
|
func NewQueryParamParser() *QueryParamParser {
|
|
return &QueryParamParser{
|
|
Errors: []codersdk.ValidationError{},
|
|
Parsed: map[string]bool{},
|
|
RequiredNotEmptyParams: map[string]bool{},
|
|
}
|
|
}
|
|
|
|
// ErrorExcessParams checks if any query params were passed in that were not
|
|
// parsed. If so, it adds an error to the parser as these values are not valid
|
|
// query parameters.
|
|
func (p *QueryParamParser) ErrorExcessParams(values url.Values) {
|
|
for k := range values {
|
|
if _, ok := p.Parsed[k]; !ok {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: k,
|
|
Detail: fmt.Sprintf("%q is not a valid query param", k),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *QueryParamParser) addParsed(key string) {
|
|
p.Parsed[key] = true
|
|
}
|
|
|
|
func (p *QueryParamParser) UInt(vals url.Values, def uint64, queryParam string) uint64 {
|
|
v, err := parseQueryParam(p, vals, func(v string) (uint64, error) {
|
|
return strconv.ParseUint(v, 10, 64)
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid positive integer: %s", queryParam, err.Error()),
|
|
})
|
|
return 0
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (p *QueryParamParser) Int(vals url.Values, def int, queryParam string) int {
|
|
v, err := parseQueryParam(p, vals, strconv.Atoi, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid integer: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (p *QueryParamParser) Int64(vals url.Values, def int64, queryParam string) int64 {
|
|
v, err := parseQueryParam(p, vals, func(v string) (int64, error) {
|
|
return strconv.ParseInt(v, 10, 64)
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid 64-bit integer: %s", queryParam, err.Error()),
|
|
})
|
|
return 0
|
|
}
|
|
return v
|
|
}
|
|
|
|
// PositiveInt32 function checks if the given value is 32-bit and positive.
|
|
//
|
|
// We can't use `uint32` as the value must be within the range <0,2147483647>
|
|
// as database expects it. Otherwise, the database query fails with `pq: OFFSET must not be negative`.
|
|
func (p *QueryParamParser) PositiveInt32(vals url.Values, def int32, queryParam string) int32 {
|
|
v, err := parseQueryParam(p, vals, func(v string) (int32, error) {
|
|
intValue, err := strconv.ParseInt(v, 10, 32)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if intValue < 0 {
|
|
return 0, xerrors.Errorf("value is negative")
|
|
}
|
|
return int32(intValue), nil
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid 32-bit positive integer: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
// PositiveInt64 function checks if the given value is 64-bit and positive.
|
|
func (p *QueryParamParser) PositiveInt64(vals url.Values, def int64, queryParam string) int64 {
|
|
v, err := parseQueryParam(p, vals, func(v string) (int64, error) {
|
|
intValue, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if intValue < 0 {
|
|
return 0, xerrors.Errorf("value is negative")
|
|
}
|
|
return intValue, nil
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid 64-bit positive integer: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
// NullableBoolean will return a null sql value if no input is provided.
|
|
// SQLc still uses sql.NullBool rather than the generic type. So converting from
|
|
// the generic type is required.
|
|
func (p *QueryParamParser) NullableBoolean(vals url.Values, def sql.NullBool, queryParam string) sql.NullBool {
|
|
v, err := parseNullableQueryParam[bool](p, vals, strconv.ParseBool, sql.Null[bool]{
|
|
V: def.Bool,
|
|
Valid: def.Valid,
|
|
}, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid boolean: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
|
|
return sql.NullBool{
|
|
Bool: v.V,
|
|
Valid: v.Valid,
|
|
}
|
|
}
|
|
|
|
func (p *QueryParamParser) Boolean(vals url.Values, def bool, queryParam string) bool {
|
|
v, err := parseQueryParam(p, vals, strconv.ParseBool, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid boolean: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (p *QueryParamParser) RequiredNotEmpty(queryParam ...string) *QueryParamParser {
|
|
for _, q := range queryParam {
|
|
p.RequiredNotEmptyParams[q] = true
|
|
}
|
|
return p
|
|
}
|
|
|
|
// UUIDorName will parse a string as a UUID, if it fails, it uses the "fetchByName"
|
|
// function to return a UUID based on the value as a string.
|
|
// This is useful when fetching something like an organization by ID or by name.
|
|
func (p *QueryParamParser) UUIDorName(vals url.Values, def uuid.UUID, queryParam string, fetchByName func(name string) (uuid.UUID, error)) uuid.UUID {
|
|
return ParseCustom(p, vals, def, queryParam, func(v string) (uuid.UUID, error) {
|
|
id, err := uuid.Parse(v)
|
|
if err == nil {
|
|
return id, nil
|
|
}
|
|
return fetchByName(v)
|
|
})
|
|
}
|
|
|
|
func (p *QueryParamParser) UUIDorMe(vals url.Values, def uuid.UUID, me uuid.UUID, queryParam string) uuid.UUID {
|
|
return ParseCustom(p, vals, def, queryParam, func(v string) (uuid.UUID, error) {
|
|
if v == "me" {
|
|
return me, nil
|
|
}
|
|
return uuid.Parse(v)
|
|
})
|
|
}
|
|
|
|
func (p *QueryParamParser) UUID(vals url.Values, def uuid.UUID, queryParam string) uuid.UUID {
|
|
v, err := parseQueryParam(p, vals, uuid.Parse, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid uuid", queryParam),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (p *QueryParamParser) UUIDs(vals url.Values, def []uuid.UUID, queryParam string) []uuid.UUID {
|
|
return ParseCustomList(p, vals, def, queryParam, func(v string) (uuid.UUID, error) {
|
|
return uuid.Parse(strings.TrimSpace(v))
|
|
})
|
|
}
|
|
|
|
func (p *QueryParamParser) RedirectURL(vals url.Values, base *url.URL, queryParam string) *url.URL {
|
|
v, err := parseQueryParam(p, vals, url.Parse, base, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid url: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
|
|
// OAuth 2.1 requires exact redirect URI matching.
|
|
if v.String() != base.String() {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must exactly match %s", queryParam, base),
|
|
})
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
func (p *QueryParamParser) Time(vals url.Values, def time.Time, queryParam, layout string) time.Time {
|
|
return p.timeWithMutate(vals, def, queryParam, layout, nil)
|
|
}
|
|
|
|
// Time uses the default time format of RFC3339Nano and always returns a UTC time.
|
|
func (p *QueryParamParser) Time3339Nano(vals url.Values, def time.Time, queryParam string) time.Time {
|
|
layout := time.RFC3339Nano
|
|
// All search queries are forced to lowercase. But the RFC format requires
|
|
// upper case letters. So just uppercase the term.
|
|
return p.timeWithMutate(vals, def, queryParam, layout, strings.ToUpper)
|
|
}
|
|
|
|
func (p *QueryParamParser) timeWithMutate(vals url.Values, def time.Time, queryParam, layout string, mutate func(term string) string) time.Time {
|
|
v, err := parseQueryParam(p, vals, func(term string) (time.Time, error) {
|
|
if mutate != nil {
|
|
term = mutate(term)
|
|
}
|
|
t, err := time.Parse(layout, term)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return t.UTC(), nil
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", queryParam, layout, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (p *QueryParamParser) String(vals url.Values, def string, queryParam string) string {
|
|
v, err := parseQueryParam(p, vals, func(v string) (string, error) {
|
|
return v, nil
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid string: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (p *QueryParamParser) Strings(vals url.Values, def []string, queryParam string) []string {
|
|
return ParseCustomList(p, vals, def, queryParam, func(v string) (string, error) {
|
|
return v, nil
|
|
})
|
|
}
|
|
|
|
func (p *QueryParamParser) JSONStringMap(vals url.Values, def map[string]string, queryParam string) map[string]string {
|
|
v, err := parseQueryParam(p, vals, func(v string) (map[string]string, error) {
|
|
var m map[string]string
|
|
if err := json.NewDecoder(strings.NewReader(v)).Decode(&m); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid JSON object: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (p *QueryParamParser) ProvisionerDaemonStatuses(vals url.Values, def []codersdk.ProvisionerDaemonStatus, queryParam string) []codersdk.ProvisionerDaemonStatus {
|
|
return ParseCustomList(p, vals, def, queryParam, func(v string) (codersdk.ProvisionerDaemonStatus, error) {
|
|
return codersdk.ProvisionerDaemonStatus(v), nil
|
|
})
|
|
}
|
|
|
|
func (p *QueryParamParser) Duration(vals url.Values, def time.Duration, queryParam string) time.Duration {
|
|
v, err := parseQueryParam(p, vals, func(v string) (time.Duration, error) {
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return d, nil
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q must be a valid duration (e.g., '24h', '30m', '1h30m'): %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
// ValidEnum represents an enum that can be parsed and validated.
|
|
type ValidEnum interface {
|
|
// Add more types as needed (avoid importing large dependency trees).
|
|
~string
|
|
|
|
// Valid is required on the enum type to be used with ParseEnum.
|
|
Valid() bool
|
|
}
|
|
|
|
// ParseEnum is a function that can be passed into ParseCustom that handles enum
|
|
// validation.
|
|
func ParseEnum[T ValidEnum](term string) (T, error) {
|
|
enum := T(term)
|
|
if enum.Valid() {
|
|
return enum, nil
|
|
}
|
|
var empty T
|
|
return empty, xerrors.Errorf("%q is not a valid value", term)
|
|
}
|
|
|
|
// ParseCustom has to be a function, not a method on QueryParamParser because generics
|
|
// cannot be used on struct methods.
|
|
func ParseCustom[T any](parser *QueryParamParser, vals url.Values, def T, queryParam string, parseFunc func(v string) (T, error)) T {
|
|
v, err := parseQueryParam(parser, vals, parseFunc, def, queryParam)
|
|
if err != nil {
|
|
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q has invalid value: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
// ParseCustomList is a function that handles csv query params or multiple values
|
|
// for a query param.
|
|
// Csv is supported as it is a common way to pass multiple values in a query param.
|
|
// Multiple values is supported (key=value&key=value2) for feature parity with GitHub issue search.
|
|
func ParseCustomList[T any](parser *QueryParamParser, vals url.Values, def []T, queryParam string, parseFunc func(v string) (T, error)) []T {
|
|
v, err := parseQueryParamSet(parser, vals, func(set []string) ([]T, error) {
|
|
// Gather all terms.
|
|
allTerms := make([]string, 0, len(set))
|
|
for _, s := range set {
|
|
// If a term is a csv, break it out into individual terms.
|
|
terms := strings.Split(s, ",")
|
|
allTerms = append(allTerms, terms...)
|
|
}
|
|
|
|
var badErrors error
|
|
var output []T
|
|
for _, s := range allTerms {
|
|
good, err := parseFunc(s)
|
|
if err != nil {
|
|
badErrors = errors.Join(badErrors, err)
|
|
continue
|
|
}
|
|
output = append(output, good)
|
|
}
|
|
if badErrors != nil {
|
|
return []T{}, badErrors
|
|
}
|
|
|
|
return output, nil
|
|
}, def, queryParam)
|
|
if err != nil {
|
|
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q has invalid values: %s", queryParam, err.Error()),
|
|
})
|
|
}
|
|
return v
|
|
}
|
|
|
|
func parseNullableQueryParam[T any](parser *QueryParamParser, vals url.Values, parse func(v string) (T, error), def sql.Null[T], queryParam string) (sql.Null[T], error) {
|
|
setParse := parseSingle(parser, parse, def.V, queryParam)
|
|
return parseQueryParamSet[sql.Null[T]](parser, vals, func(set []string) (sql.Null[T], error) {
|
|
if len(set) == 0 {
|
|
return sql.Null[T]{
|
|
Valid: false,
|
|
}, nil
|
|
}
|
|
|
|
value, err := setParse(set)
|
|
if err != nil {
|
|
return sql.Null[T]{}, err
|
|
}
|
|
return sql.Null[T]{
|
|
V: value,
|
|
Valid: true,
|
|
}, nil
|
|
}, def, queryParam)
|
|
}
|
|
|
|
// parseQueryParam expects just 1 value set for the given query param.
|
|
func parseQueryParam[T any](parser *QueryParamParser, vals url.Values, parse func(v string) (T, error), def T, queryParam string) (T, error) {
|
|
setParse := parseSingle(parser, parse, def, queryParam)
|
|
return parseQueryParamSet(parser, vals, setParse, def, queryParam)
|
|
}
|
|
|
|
func parseSingle[T any](parser *QueryParamParser, parse func(v string) (T, error), def T, queryParam string) func(set []string) (T, error) {
|
|
return func(set []string) (T, error) {
|
|
if len(set) > 1 {
|
|
// Set as a parser.Error rather than return an error.
|
|
// Returned errors are errors from the passed in `parse` function, and
|
|
// imply the query param value had attempted to be parsed.
|
|
// By raising the error this way, we can also more easily control how it
|
|
// is presented to the user. A returned error is wrapped with more text.
|
|
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q provided more than once, found %d times. Only provide 1 instance of this query param.", queryParam, len(set)),
|
|
})
|
|
return def, nil
|
|
}
|
|
return parse(set[0])
|
|
}
|
|
}
|
|
|
|
func parseQueryParamSet[T any](parser *QueryParamParser, vals url.Values, parse func(set []string) (T, error), def T, queryParam string) (T, error) {
|
|
parser.addParsed(queryParam)
|
|
// If the query param is required and not present, return an error.
|
|
if parser.RequiredNotEmptyParams[queryParam] && (!vals.Has(queryParam) || vals.Get(queryParam) == "") {
|
|
parser.Errors = append(parser.Errors, codersdk.ValidationError{
|
|
Field: queryParam,
|
|
Detail: fmt.Sprintf("Query param %q is required and cannot be empty", queryParam),
|
|
})
|
|
return def, nil
|
|
}
|
|
|
|
// If the query param is not present, return the default value.
|
|
if !vals.Has(queryParam) || vals.Get(queryParam) == "" {
|
|
return def, nil
|
|
}
|
|
|
|
return parse(vals[queryParam])
|
|
}
|