Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 144 additions & 36 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package huma

import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -216,7 +218,10 @@ type Config struct {
// JSON-marshaled into the docs HTML. Scalar and SwaggerUI use it, Stoplight
// Elements ignores it.
//
// Scalar reads it from the `data-configuration` attribute. See
// Scalar receives it via `Scalar.createApiReference`, so all options
// including multi-document `sources` are supported; it must marshal to a
// JSON object. Huma sets the `url` field to the OpenAPI spec unless
// `url`, `sources`, or `content` is set here. See
// https://github.com/scalar/scalar/blob/main/documentation/configuration.md
// for the options.
//
Expand All @@ -226,6 +231,10 @@ type Config struct {
// them here does nothing.
DocsRendererConfig any

// DocsScalar configures the Scalar docs renderer script and CSP. The zero
// value uses the built-in defaults. Other renderers ignore it.
DocsScalar ScalarDocsConfig

// SchemasPath is the path to the API schemas. If set to `/schemas` it will
// allow clients to get `/schemas/{schema}` to view the schema in a browser
// or for use in editors like VSCode to provide autocomplete & validation.
Expand Down Expand Up @@ -259,6 +268,27 @@ type Config struct {
CreateHooks []func(Config) Config
}

// ScalarDocsConfig customizes the Scalar docs renderer. The zero value uses
// the built-in pinned `@scalar/api-reference` script with its integrity hash
// and the default `https://fonts.scalar.com` font source.
type ScalarDocsConfig struct {
// ScriptURL overrides the docs renderer script URL, for example to use a
// newer `@scalar/api-reference` version than the built-in default without
// waiting for a Huma release.
ScriptURL string

// ScriptIntegrity is the Subresource Integrity hash (e.g. `sha384-...`)
// for the script at ScriptURL. It is only used when ScriptURL is set. If
// left empty, the script tag is emitted without an `integrity` attribute,
// disabling integrity verification.
ScriptIntegrity string

// FontSrc overrides the CSP `font-src` source list, for example
// `'self' data:` or another font host if a custom ScriptURL bundle loads
// fonts from somewhere other than the default `https://fonts.scalar.com`.
FontSrc string
}

// configProvider is an internal interface to get the configuration from an
// implementation of the API or Registry. This is used to access settings
// without exposing them through public interfaces.
Expand Down Expand Up @@ -627,6 +657,112 @@ func NewAPI(config Config, a Adapter) API {
return newAPI
}

func validateDocsScriptValue(name, value string) {
if strings.ContainsAny(value, ";,'\" \t\r\n<>") {
panic("invalid " + name + ": must not contain whitespace, quotes, ';', ',', '<', or '>'")
}
}

const scalarDefaultScriptURL = "https://unpkg.com/@scalar/api-reference@1.66.1/dist/browser/standalone.js"
const scalarDefaultScriptIntegrity = "sha384-RkhHYpdjsrJH9sH8RmczPchxNiHEhmW300QwMB/8yg6feduTZu9FBN4W0DJnp50Z"
const scalarDefaultFontSrc = "https://fonts.scalar.com"

type scalarScript struct {
url string
integrity string
}

func scalarScriptFor(config ScalarDocsConfig) scalarScript {
if config.ScriptURL == "" {
return scalarScript{url: scalarDefaultScriptURL, integrity: scalarDefaultScriptIntegrity}
}
validateDocsScriptValue("DocsScalar.ScriptURL", config.ScriptURL)
if config.ScriptIntegrity != "" {
validateDocsScriptValue("DocsScalar.ScriptIntegrity", config.ScriptIntegrity)
}
return scalarScript{url: config.ScriptURL, integrity: config.ScriptIntegrity}
}

func scalarFontSrc(config ScalarDocsConfig) string {
if config.FontSrc == "" {
return scalarDefaultFontSrc
}
if strings.ContainsAny(config.FontSrc, ";,<>\r\n") {
panic("invalid DocsScalar.FontSrc: must not contain ';', ',', '<', '>', or newlines")
}
return config.FontSrc
}

// scalarConfigJSON builds the object passed to `Scalar.createApiReference`
// from DocsRendererConfig, pointing `url` at the OpenAPI spec unless the
// config already declares a document via `url`, `sources`, or `content`.
func scalarConfigJSON(config Config, openAPIPath string) []byte {
docsConfig := map[string]json.RawMessage{}
if config.DocsRendererConfig != nil {
b, err := json.Marshal(config.DocsRendererConfig)
if err != nil {
panic("failed to marshal DocsRendererConfig: " + err.Error())
}
if err := json.Unmarshal(b, &docsConfig); err != nil {
panic("DocsRendererConfig must be a JSON object: " + err.Error())
}
}

_, hasURL := docsConfig["url"]
_, hasSources := docsConfig["sources"]
_, hasContent := docsConfig["content"]
if !hasURL && !hasSources && !hasContent {
u, err := json.Marshal(openAPIPath + ".json")
if err != nil {
panic("failed to marshal OpenAPI path: " + err.Error())
}
docsConfig["url"] = u
}

out, err := json.Marshal(docsConfig)
if err != nil {
panic("failed to marshal DocsRendererConfig: " + err.Error())
}
return out
}

func scalarCSP(config ScalarDocsConfig, script scalarScript, initScript string) []string {
initHash := sha256.Sum256([]byte(initScript))
return []string{
"default-src 'none'",
"base-uri 'none'",
"connect-src 'self'",
"font-src " + scalarFontSrc(config),
"form-action 'none'",
"frame-ancestors 'none'",
"sandbox allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-downloads",
"script-src " + script.url + " 'sha256-" + base64.StdEncoding.EncodeToString(initHash[:]) + "'",
"style-src 'unsafe-inline'", // TODO: Somehow drop 'unsafe-inline'
}
}

func scalarPage(title string, script scalarScript, initScript string) []byte {
integrityAttr := ""
if script.integrity != "" {
integrityAttr = ` integrity="` + script.integrity + `"`
}

return []byte(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>` + html.EscapeString(title) + `</title>
</head>
<body>
<div id="app"></div>
<script src="` + script.url + `" crossorigin` + integrityAttr + `></script>
<script>` + initScript + `</script>
</body>
</html>`)
}

func (a *api) registerDocsRoute() {
openAPIPath := a.config.OpenAPIPath
if prefix := getAPIPrefix(a.OpenAPI()); prefix != "" {
Expand All @@ -650,40 +786,10 @@ func (a *api) registerDocsRoute() {
if title == "" {
title = "Scalar in HTML"
}

csp = []string{
"default-src 'none'",
"base-uri 'none'",
"connect-src 'self'",
"form-action 'none'",
"frame-ancestors 'none'",
"sandbox allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-downloads",
"script-src 'unsafe-eval' https://unpkg.com/@scalar/api-reference@1.44.20/dist/browser/standalone.js", // TODO: Somehow drop 'unsafe-eval'
"style-src 'unsafe-inline'", // TODO: Somehow drop 'unsafe-inline'
}

var configAttr string
if a.config.DocsRendererConfig != nil {
b, err := json.Marshal(a.config.DocsRendererConfig)
if err != nil {
panic("failed to marshal DocsRendererConfig: " + err.Error())
}
configAttr = ` data-configuration="` + html.EscapeString(string(b)) + `"`
}

body = []byte(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>` + title + `</title>
</head>
<body>
<script id="api-reference" data-url="` + openAPIPath + `.json"` + configAttr + `></script>
<script src="https://unpkg.com/@scalar/api-reference@1.44.20/dist/browser/standalone.js" crossorigin integrity="sha384-tMz7GAo6dMy55x9tLFtH+sHtogji6Scmb+feBR31TAHmvSPRUTboK9H3M5NFaP4R"></script>
</body>
</html>`)
script := scalarScriptFor(a.config.DocsScalar)
initScript := "Scalar.createApiReference('#app', " + string(scalarConfigJSON(a.config, openAPIPath)) + ")"
csp = scalarCSP(a.config.DocsScalar, script, initScript)
body = scalarPage(title, script, initScript)
case DocsRendererStoplightElements:
if title == "" {
title = "Elements in HTML"
Expand Down Expand Up @@ -778,11 +884,13 @@ func (a *api) registerDocsRoute() {
panic("missing CSP for docs renderer: " + a.config.DocsRenderer)
}

cspHeader := strings.Join(csp, "; ")

a.adapter.Handle(&Operation{
Method: http.MethodGet,
Path: a.config.DocsPath,
}, func(ctx Context) {
ctx.SetHeader("Content-Security-Policy", strings.Join(csp, "; "))
ctx.SetHeader("Content-Security-Policy", cspHeader)
ctx.SetHeader("Content-Type", "text/html")
ctx.BodyWriter().Write(body)
})
Expand Down
Loading