Files
coder/coderd/httpapi/queryparams.go
T
Thomas Kosiewski b776a14b46 fix(coderd): harden OAuth2 provider security (#22194)
## 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`_
2026-02-23 12:18:44 +01:00

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])
}