Files
coder/site/site.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

748 lines
21 KiB
Go

package site
import (
"bytes"
"context"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"fmt"
"html"
htmltemplate "html/template"
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"text/template" // html/template escapes some nonces
"time"
"github.com/google/uuid"
"github.com/justinas/nosurf"
"github.com/unrolled/secure"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/appearance"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/codersdk"
)
// We always embed the error page HTML because it it doesn't need to be built,
// and it's tiny and doesn't contribute much to the binary size.
var (
//go:embed static/error.html
errorHTML string
errorTemplate *htmltemplate.Template
//go:embed static/oauth2allow.html
oauthHTML string
oauthTemplate *htmltemplate.Template
)
func init() {
var err error
errorTemplate, err = htmltemplate.New("error").Parse(errorHTML)
if err != nil {
panic(err)
}
oauthTemplate, err = htmltemplate.New("error").Parse(oauthHTML)
if err != nil {
panic(err)
}
}
type Options struct {
CacheDir string
Database database.Store
SiteFS fs.FS
OAuth2Configs *httpmw.OAuth2Configs
DocsURL string
BuildInfo codersdk.BuildInfoResponse
AppearanceFetcher *atomic.Pointer[appearance.Fetcher]
Entitlements *entitlements.Set
Telemetry telemetry.Reporter
Logger slog.Logger
HideAITasks bool
}
func New(opts *Options) (*Handler, error) {
if opts.AppearanceFetcher == nil {
daf := atomic.Pointer[appearance.Fetcher]{}
f := appearance.NewDefaultFetcher(opts.DocsURL)
daf.Store(&f)
opts.AppearanceFetcher = &daf
}
handler := &Handler{
opts: opts,
secureHeaders: secureHeaders(),
Entitlements: opts.Entitlements,
}
// html files are handled by a text/template. Non-html files
// are served by the default file server.
var err error
handler.htmlTemplates, err = findAndParseHTMLFiles(opts.SiteFS)
if err != nil {
return nil, xerrors.Errorf("failed to parse html files: %w", err)
}
binHand, err := newBinHandler(opts)
if err != nil {
return nil, xerrors.Errorf("create bin handler: %w", err)
}
mux := http.NewServeMux()
mux.Handle("/bin/", binHand)
mux.Handle("/", http.FileServer(
http.FS(
// OnlyFiles is a wrapper around the file system that prevents directory
// listings. Directory listings are not required for the site file system, so we
// exclude it as a security measure. In practice, this file system comes from our
// open source code base, but this is considered a best practice for serving
// static files.
OnlyFiles(opts.SiteFS))),
)
buildInfoResponse, err := json.Marshal(opts.BuildInfo)
if err != nil {
return nil, xerrors.Errorf("failed to marshal build info: %w", err)
}
handler.buildInfoJSON = html.EscapeString(string(buildInfoResponse))
handler.handler = mux.ServeHTTP
handler.installScript, err = parseInstallScript(opts.SiteFS, opts.BuildInfo)
if err != nil {
opts.Logger.Warn(context.Background(), "could not parse install.sh, it will be unavailable", slog.Error(err))
}
return handler, nil
}
type Handler struct {
opts *Options
secureHeaders *secure.Secure
handler http.HandlerFunc
htmlTemplates *template.Template
buildInfoJSON string
installScript []byte
// RegionsFetcher will attempt to fetch the more detailed WorkspaceProxy data, but will fall back to the
// regions if the user does not have the correct permissions.
RegionsFetcher func(ctx context.Context) (any, error)
Entitlements *entitlements.Set
Experiments atomic.Pointer[codersdk.Experiments]
telemetryHTMLServedOnce sync.Once
}
func (h *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
err := h.secureHeaders.Process(rw, r)
if err != nil {
return
}
// reqFile is the static file requested
reqFile := filePath(r.URL.Path)
state := htmlState{
// Token is the CSRF token for the given request
CSRF: csrfState{Token: nosurf.Token(r)},
BuildInfo: h.buildInfoJSON,
DocsURL: h.opts.DocsURL,
}
// First check if it's a file we have in our templates
if h.serveHTML(rw, r, reqFile, state) {
return
}
switch {
// If requesting binaries, serve straight up.
case reqFile == "bin" || strings.HasPrefix(reqFile, "bin/"):
h.handler.ServeHTTP(rw, r)
return
// If requesting assets, serve straight up with caching.
case reqFile == "assets" || strings.HasPrefix(reqFile, "assets/") || strings.HasPrefix(reqFile, "icon/"):
// It could make sense to cache 404s, but the problem is that during an
// upgrade a load balancer may route partially to the old server, and that
// would make new asset paths get cached as 404s and not load even once the
// new server was in place. To combat that, only cache if we have the file.
if h.exists(reqFile) && ShouldCacheFile(reqFile) {
rw.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
}
// If the asset does not exist, this will return a 404.
h.handler.ServeHTTP(rw, r)
return
// If requesting the install.sh script, respond with the preprocessed version
// which contains the correct hostname and version information.
case reqFile == "install.sh":
if h.installScript == nil {
http.NotFound(rw, r)
return
}
rw.Header().Add("Content-Type", "text/plain; charset=utf-8")
http.ServeContent(rw, r, reqFile, time.Time{}, bytes.NewReader(h.installScript))
return
// If the original file path exists we serve it.
case h.exists(reqFile):
if ShouldCacheFile(reqFile) {
rw.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
}
h.handler.ServeHTTP(rw, r)
return
}
// Serve the file assuming it's an html file
// This matches paths like `/app/terminal.html`
r.URL.Path = strings.TrimSuffix(r.URL.Path, "/")
r.URL.Path += ".html"
reqFile = filePath(r.URL.Path)
// All html files should be served by the htmlFile templates
if h.serveHTML(rw, r, reqFile, state) {
return
}
// If we don't have the file... we should redirect to `/`
// for our single-page-app.
r.URL.Path = "/"
if h.serveHTML(rw, r, "", state) {
return
}
// This will send a correct 404
h.handler.ServeHTTP(rw, r)
}
// filePath returns the filepath of the requested file.
func filePath(p string) string {
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return strings.TrimPrefix(path.Clean(p), "/")
}
func (h *Handler) exists(filePath string) bool {
f, err := h.opts.SiteFS.Open(filePath)
if err == nil {
_ = f.Close()
}
return err == nil
}
type htmlState struct {
CSRF csrfState
// Below are HTML escaped JSON strings of the respective structs.
ApplicationName string
LogoURL string
BuildInfo string
User string
Entitlements string
Appearance string
UserAppearance string
Experiments string
Regions string
DocsURL string
TasksTabVisible string
}
type csrfState struct {
Token string
}
func ShouldCacheFile(reqFile string) bool {
// Images, favicons and uniquely content hashed bundle assets should be
// cached. By default, we cache everything in the site/out directory except
// for deny-listed items enumerated here. The reason for this approach is that
// cache invalidation techniques should be used by default for all build
// processed assets. The scenarios where we don't use cache invalidation
// techniques are one-offs or things that should have invalidation in the
// future.
denyListedSuffixes := []string{
".html",
"worker.js",
}
for _, suffix := range denyListedSuffixes {
if strings.HasSuffix(reqFile, suffix) {
return false
}
}
return true
}
// reportHTMLFirstServedAt sends a telemetry report when the first HTML is ever served.
// The purpose is to track the first time the first user opens the site.
func (h *Handler) reportHTMLFirstServedAt() {
// nolint:gocritic // Manipulating telemetry items is system-restricted.
// TODO(hugodutka): Add a telemetry context in RBAC.
ctx := dbauthz.AsSystemRestricted(context.Background())
itemKey := string(telemetry.TelemetryItemKeyHTMLFirstServedAt)
_, err := h.opts.Database.GetTelemetryItem(ctx, itemKey)
if err == nil {
// If the value is already set, then we reported it before.
// We don't need to report it again.
return
}
if !errors.Is(err, sql.ErrNoRows) {
h.opts.Logger.Debug(ctx, "failed to get telemetry html first served at", slog.Error(err))
return
}
if err := h.opts.Database.InsertTelemetryItemIfNotExists(ctx, database.InsertTelemetryItemIfNotExistsParams{
Key: string(telemetry.TelemetryItemKeyHTMLFirstServedAt),
Value: time.Now().Format(time.RFC3339),
}); err != nil {
h.opts.Logger.Debug(ctx, "failed to set telemetry html first served at", slog.Error(err))
return
}
item, err := h.opts.Database.GetTelemetryItem(ctx, itemKey)
if err != nil {
h.opts.Logger.Debug(ctx, "failed to get telemetry html first served at", slog.Error(err))
return
}
h.opts.Telemetry.Report(&telemetry.Snapshot{
TelemetryItems: []telemetry.TelemetryItem{telemetry.ConvertTelemetryItem(item)},
})
}
func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
if data, err := h.renderHTMLWithState(request, reqPath, state); err == nil {
if reqPath == "" {
// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
reqPath = "index.html"
}
// `Once` is used to reduce the volume of db calls and telemetry reports.
// It's fine to run the enclosed function multiple times, but it's unnecessary.
h.telemetryHTMLServedOnce.Do(func() {
go h.reportHTMLFirstServedAt()
})
http.ServeContent(resp, request, reqPath, time.Time{}, bytes.NewReader(data))
return true
}
return false
}
func execTmpl(tmpl *template.Template, state htmlState) ([]byte, error) {
var buf bytes.Buffer
err := tmpl.Execute(&buf, state)
return buf.Bytes(), err
}
// renderWithState will render the file using the given nonce if the file exists
// as a template. If it does not, it will return an error.
func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state htmlState) ([]byte, error) {
af := *(h.opts.AppearanceFetcher.Load())
if filePath == "" {
filePath = "index.html"
}
tmpl := h.htmlTemplates.Lookup(filePath)
if tmpl == nil {
return nil, xerrors.Errorf("template %q not found", filePath)
}
// Cookies are sent when requesting HTML, so we can get the user
// and pre-populate the state for the frontend to reduce requests.
// We use a noop response writer because we don't want to write
// anything to the response and break the HTML, an error means we
// simply don't pre-populate the state.
noopRW := noopResponseWriter{}
apiKey, actor, ok := httpmw.ExtractAPIKey(noopRW, r, httpmw.ExtractAPIKeyConfig{
Optional: true,
DB: h.opts.Database,
OAuth2Configs: h.opts.OAuth2Configs,
// Special case for site, we can always disable refresh here because
// the frontend will perform API requests if this fails.
DisableSessionExpiryRefresh: true,
RedirectToLogin: false,
SessionTokenFunc: nil,
})
if !ok || apiKey == nil || actor == nil {
var cfg codersdk.AppearanceConfig
// nolint:gocritic // User is not expected to be signed in.
ctx := dbauthz.AsSystemRestricted(r.Context())
cfg, _ = af.Fetch(ctx)
state.ApplicationName = applicationNameOrDefault(cfg)
state.LogoURL = cfg.LogoURL
return execTmpl(tmpl, state)
}
ctx := dbauthz.As(r.Context(), *actor)
var eg errgroup.Group
var user database.User
var themePreference string
var terminalFont string
orgIDs := []uuid.UUID{}
eg.Go(func() error {
var err error
user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID)
return err
})
eg.Go(func() error {
var err error
themePreference, err = h.opts.Database.GetUserThemePreference(ctx, apiKey.UserID)
if errors.Is(err, sql.ErrNoRows) {
themePreference = ""
return nil
}
return err
})
eg.Go(func() error {
var err error
terminalFont, err = h.opts.Database.GetUserTerminalFont(ctx, apiKey.UserID)
if errors.Is(err, sql.ErrNoRows) {
terminalFont = ""
return nil
}
return err
})
eg.Go(func() error {
memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID})
if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 {
return nil
}
if err != nil {
return nil
}
orgIDs = memberIDs[0].OrganizationIDs
return err
})
err := eg.Wait()
if err == nil {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
user, err := json.Marshal(db2sdk.User(user, orgIDs))
if err == nil {
state.User = html.EscapeString(string(user))
}
}()
wg.Add(1)
go func() {
defer wg.Done()
userAppearance, err := json.Marshal(codersdk.UserAppearanceSettings{
ThemePreference: themePreference,
TerminalFont: codersdk.TerminalFontName(terminalFont),
})
if err == nil {
state.UserAppearance = html.EscapeString(string(userAppearance))
}
}()
if h.Entitlements != nil {
wg.Add(1)
go func() {
defer wg.Done()
state.Entitlements = html.EscapeString(string(h.Entitlements.AsJSON()))
}()
}
wg.Add(1)
go func() {
defer wg.Done()
cfg, err := af.Fetch(ctx)
if err == nil {
appr, err := json.Marshal(cfg)
if err == nil {
state.Appearance = html.EscapeString(string(appr))
state.ApplicationName = applicationNameOrDefault(cfg)
state.LogoURL = cfg.LogoURL
}
}
}()
if h.RegionsFetcher != nil {
wg.Add(1)
go func() {
defer wg.Done()
regions, err := h.RegionsFetcher(ctx)
if err == nil {
regions, err := json.Marshal(regions)
if err == nil {
state.Regions = html.EscapeString(string(regions))
}
}
}()
}
experiments := h.Experiments.Load()
if experiments != nil {
wg.Add(1)
go func() {
defer wg.Done()
experiments, err := json.Marshal(experiments)
if err == nil {
state.Experiments = html.EscapeString(string(experiments))
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
tasksTabVisible, err := json.Marshal(!h.opts.HideAITasks)
if err == nil {
state.TasksTabVisible = html.EscapeString(string(tasksTabVisible))
}
}()
wg.Wait()
}
return execTmpl(tmpl, state)
}
// noopResponseWriter is a response writer that does nothing.
type noopResponseWriter struct{}
func (noopResponseWriter) Header() http.Header { return http.Header{} }
func (noopResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
func (noopResponseWriter) WriteHeader(int) {}
// secureHeaders is only needed for statically served files. We do not need this for api endpoints.
// It adds various headers to enforce browser security features.
func secureHeaders() *secure.Secure {
// Permissions-Policy can be used to disabled various browser features that we do not use.
// This can prevent an embedded iframe from accessing these features.
// If we support arbitrary iframes such as generic applications, we might need to add permissions
// based on the app here.
permissions := strings.Join([]string{
// =() means it is disabled
"accelerometer=()",
"autoplay=()",
"battery=()",
"camera=()",
"document-domain=()",
"geolocation=()",
"gyroscope=()",
"magnetometer=()",
"microphone=()",
"midi=()",
"payment=()",
"usb=()",
"vr=()",
"screen-wake-lock=()",
"xr-spatial-tracking=()",
}, ", ")
return secure.New(secure.Options{
PermissionsPolicy: permissions,
// Prevent the browser from sending Referrer header with requests
ReferrerPolicy: "no-referrer",
})
}
// findAndParseHTMLFiles recursively walks the file system passed finding all *.html files.
// The template returned has all html files parsed.
func findAndParseHTMLFiles(files fs.FS) (*template.Template, error) {
// root is the collection of html templates. All templates are named by their pathing.
// So './404.html' is named '404.html'. './subdir/index.html' is 'subdir/index.html'
root := template.New("")
rootPath := "."
err := fs.WalkDir(files, rootPath, func(filePath string, directory fs.DirEntry, err error) error {
if err != nil {
return err
}
if directory.IsDir() {
return nil
}
if filepath.Ext(directory.Name()) != ".html" {
return nil
}
file, err := files.Open(filePath)
if err != nil {
return err
}
data, err := io.ReadAll(file)
if err != nil {
return err
}
tPath := strings.TrimPrefix(filePath, rootPath+string(filepath.Separator))
_, err = root.New(tPath).Parse(string(data))
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return root, nil
}
type installScriptState struct {
Origin string
Version string
}
func parseInstallScript(files fs.FS, buildInfo codersdk.BuildInfoResponse) ([]byte, error) {
scriptFile, err := fs.ReadFile(files, "install.sh")
if err != nil {
return nil, err
}
script, err := template.New("install.sh").Parse(string(scriptFile))
if err != nil {
return nil, err
}
var buf bytes.Buffer
state := installScriptState{Origin: buildInfo.DashboardURL, Version: buildInfo.Version}
err = script.Execute(&buf, state)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Action represents a link.
type Action struct {
// URL is set as the href property on the anchor. If empty, refreshes the
// page instead.
URL string
// Text is the displayed text of the button or link.
Text string
}
// ErrorPageData contains the variables that are found in
// site/static/error.html.
type ErrorPageData struct {
Status int
// HideStatus will remove the status code from the page.
HideStatus bool
Title string
Description string
Actions []Action
Warnings []string
AdditionalInfo string
RenderDescriptionMarkdown bool
}
// RenderStaticErrorPage renders the static error page. This is used by app
// requests to avoid dependence on the dashboard but maintain the ability to
// render a friendly error page on subdomains.
func RenderStaticErrorPage(rw http.ResponseWriter, r *http.Request, data ErrorPageData) {
type outerData struct {
Error ErrorPageData
ErrorDescriptionHTML htmltemplate.HTML
}
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(data.Status)
err := errorTemplate.Execute(rw, outerData{
Error: data,
ErrorDescriptionHTML: htmltemplate.HTML(data.Description), //nolint:gosec // gosec thinks this is user-input, but it is from Coder deployment configuration.
})
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to render error page: " + err.Error(),
Detail: fmt.Sprintf("Original error was: %d %s, %s", data.Status, data.Title, data.Description),
})
return
}
}
func applicationNameOrDefault(cfg codersdk.AppearanceConfig) string {
if cfg.ApplicationName != "" {
return cfg.ApplicationName
}
return "Coder"
}
// OnlyFiles returns a new fs.FS that only contains files. If a directory is
// requested, os.ErrNotExist is returned. This prevents directory listings from
// being served.
func OnlyFiles(files fs.FS) fs.FS {
return justFilesSystem{FS: files}
}
type justFilesSystem struct {
FS fs.FS
}
func (jfs justFilesSystem) Open(name string) (fs.File, error) {
f, err := jfs.FS.Open(name)
if err != nil {
return nil, err
}
stat, err := f.Stat()
if err != nil {
return nil, err
}
// Returning a 404 here does prevent the http.FileServer from serving
// index.* files automatically. Coder handles this above as all index pages
// are considered template files. So we never relied on this behavior.
if stat.IsDir() {
return nil, os.ErrNotExist
}
return f, nil
}
// RenderOAuthAllowData contains the variables that are found in
// site/static/oauth2allow.html.
type RenderOAuthAllowData struct {
AppIcon string
AppName string
CancelURI string
RedirectURI string
CSRFToken string
Username string
}
// RenderOAuthAllowPage renders the static page for a user to "Allow" an create
// a new oauth2 link with an external site. This is when Coder is acting as the
// identity provider.
//
// This has to be done statically because Golang has to handle the full request.
// It cannot defer to the FE typescript easily.
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")
err := oauthTemplate.Execute(rw, data)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
Message: "Failed to render oauth page: " + err.Error(),
})
return
}
}