mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
83fd4cf5c2
Go's html/template has a built-in security filter (urlFilter) that only allows http, https, and mailto URL schemes. Any other scheme gets replaced with #ZgotmplZ. The OAuth2 app's callback URL uses custom URI scheme which the filter considers unsafe. For example the Coder JetBrains plugin exposes a callback URI with the scheme jetbrains:// - which was effectively changed by the template engine into #ZgotmplZ. Of course this is not an actual callback. When users clicked the cancel button nothing happened. The fix was simple - we now wrap the apps registered callback URI into htmltemplate.URL. Usually this needs some validation otherwise the linter will complain about it. The callback URI used by the Cancel logic is actually validated by our backend when the client app programmatically registered via the dynamic OAuth2 registration endpoints, so we refactored the validation around that code and re-used some of it in the Cancel handling to make sure we don't allow URIs like `javascript` and `data`, even though in theory these URIs were already validated. In addition, while testing this PR with https://github.com/coder/coder-jetbrains-toolbox/pull/209 I discovered that we are also not compliant with https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1 which requires the server to attach the local state if it was provided by the client in the original request. Also it is optional but generally a good practice to include `error_description` in the error responses. In fact we follow this pattern for the other types of error responses. So this is not a one off. - resolves #20323 <img width="1485" height="771" alt="Cancel_page_with_invalid_uri" src="https://github.com/user-attachments/assets/5539d234-9ce3-4dda-b421-d023fc9aa99e" /> <img width="486" height="746" alt="Coder Toolbox handling the Cancel button" src="https://github.com/user-attachments/assets/acab71a6-d29c-4fa9-80ba-3c0095bbdc8f" /> <!-- If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting. -->
832 lines
24 KiB
Go
832 lines
24 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/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/coderd/util/slice"
|
|
"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
|
|
Authorizer rbac.Authorizer
|
|
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
|
|
AgentsTabVisible string
|
|
Permissions string
|
|
Organizations 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{}
|
|
var userOrgs []database.Organization
|
|
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
|
|
})
|
|
eg.Go(func() error {
|
|
orgs, err := h.opts.Database.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
|
|
UserID: apiKey.UserID,
|
|
})
|
|
if err == nil {
|
|
userOrgs = orgs
|
|
}
|
|
// Don't fail the entire group if we can't fetch orgs.
|
|
return nil
|
|
})
|
|
err := eg.Wait()
|
|
if err == nil {
|
|
h.populateHTMLState(ctx, &state, af, actor, user, orgIDs, userOrgs, themePreference, terminalFont)
|
|
}
|
|
|
|
return execTmpl(tmpl, state)
|
|
}
|
|
|
|
// populateHTMLState runs concurrent goroutines to populate all
|
|
// authenticated user metadata in the HTML state. This is extracted
|
|
// from renderHTMLWithState to reduce nesting complexity.
|
|
func (h *Handler) populateHTMLState(
|
|
ctx context.Context,
|
|
state *htmlState,
|
|
af appearance.Fetcher,
|
|
actor *rbac.Subject,
|
|
user database.User,
|
|
orgIDs []uuid.UUID,
|
|
userOrgs []database.Organization,
|
|
themePreference string,
|
|
terminalFont string,
|
|
) {
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
data, err := json.Marshal(db2sdk.User(user, orgIDs))
|
|
if err == nil {
|
|
state.User = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
wg.Go(func() {
|
|
data, err := json.Marshal(codersdk.UserAppearanceSettings{
|
|
ThemePreference: themePreference,
|
|
TerminalFont: codersdk.TerminalFontName(terminalFont),
|
|
})
|
|
if err == nil {
|
|
state.UserAppearance = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
if h.Entitlements != nil {
|
|
wg.Go(func() {
|
|
state.Entitlements = html.EscapeString(string(h.Entitlements.AsJSON()))
|
|
})
|
|
}
|
|
wg.Go(func() {
|
|
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.Go(func() {
|
|
regions, err := h.RegionsFetcher(ctx)
|
|
if err == nil {
|
|
data, err := json.Marshal(regions)
|
|
if err == nil {
|
|
state.Regions = html.EscapeString(string(data))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
experiments := h.Experiments.Load()
|
|
if experiments != nil {
|
|
wg.Go(func() {
|
|
data, err := json.Marshal(experiments)
|
|
if err == nil {
|
|
state.Experiments = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
}
|
|
wg.Go(func() {
|
|
data, err := json.Marshal(!h.opts.HideAITasks)
|
|
if err == nil {
|
|
state.TasksTabVisible = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
wg.Go(func() {
|
|
agentsTabVisible := false
|
|
if experiments != nil {
|
|
agentsTabVisible = experiments.Enabled(codersdk.ExperimentAgents)
|
|
}
|
|
data, err := json.Marshal(agentsTabVisible)
|
|
if err == nil {
|
|
state.AgentsTabVisible = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
wg.Go(func() {
|
|
sdkOrgs := slice.List(userOrgs, db2sdk.Organization)
|
|
data, err := json.Marshal(sdkOrgs)
|
|
if err == nil {
|
|
state.Organizations = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
if h.opts.Authorizer != nil {
|
|
wg.Go(func() {
|
|
state.Permissions = h.renderPermissions(ctx, *actor)
|
|
})
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// permissionChecks is the single source of truth for site-wide
|
|
// permission checks, shared with the TypeScript frontend via
|
|
// permissions.json.
|
|
//
|
|
//go:embed permissions.json
|
|
var permissionChecksJSON []byte
|
|
|
|
var permissionChecks map[string]codersdk.AuthorizationCheck
|
|
|
|
func init() {
|
|
if err := json.Unmarshal(permissionChecksJSON, &permissionChecks); err != nil {
|
|
panic("failed to parse permissions.json: " + err.Error())
|
|
}
|
|
}
|
|
|
|
// renderPermissions checks all the site-wide permissions for the
|
|
// given actor and returns an HTML-escaped JSON string suitable for
|
|
// embedding in a meta tag.
|
|
func (h *Handler) renderPermissions(ctx context.Context, actor rbac.Subject) string {
|
|
response := make(codersdk.AuthorizationResponse)
|
|
for k, v := range permissionChecks {
|
|
// Resolve the "me" sentinel so permission checks
|
|
// run against the actual actor, matching the
|
|
// API-side handling in coderd/authorize.go.
|
|
ownerID := v.Object.OwnerID
|
|
if ownerID == codersdk.Me {
|
|
ownerID = actor.ID
|
|
}
|
|
obj := rbac.Object{
|
|
ID: v.Object.ResourceID,
|
|
Owner: ownerID,
|
|
OrgID: v.Object.OrganizationID,
|
|
AnyOrgOwner: v.Object.AnyOrgOwner,
|
|
Type: string(v.Object.ResourceType),
|
|
}
|
|
err := h.opts.Authorizer.Authorize(ctx, actor, policy.Action(v.Action), obj)
|
|
response[k] = err == nil
|
|
}
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return html.EscapeString(string(data))
|
|
}
|
|
|
|
// 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=(self)",
|
|
"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 htmltemplate.URL
|
|
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
|
|
}
|
|
}
|