Files
2026-08-15 07:13:00 +00:00

108 lines
3.8 KiB
Go

package adminapi
import (
"io/fs"
"strings"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/config"
)
// Field length caps. They exist to stop a client filling the database with a
// megabyte of display name, not to express any semantic limit.
const (
maxDisplayNameLen = 200
maxKeyNameLen = 200
maxSitePathLen = 1024
maxCacheControlLen = 256
)
// checkProjectName validates a name before it becomes a row and, later, a
// "~name" entry inside $WEBROOT. The pattern is the only thing standing between
// a project name and the filesystem, so this is a hard reject rather than a
// normalisation.
func checkProjectName(name string) error {
if name == "" {
return api.Errorf(api.CodeInvalidProjectName, "project name is required")
}
if !config.ProjectNamePattern.MatchString(name) {
return api.Errorf(api.CodeInvalidProjectName,
"project name must match %s", config.ProjectNamePattern.String())
}
return nil
}
// checkText rejects control characters in free-text fields.
//
// These strings are printed to operator terminals by "pages project list" and
// written into structured logs. An embedded ESC would let whoever set the field
// move the cursor, recolour the output or rewrite the line the operator is
// reading; a newline would forge a second log record.
func checkText(field, v string, max int) error {
if len(v) > max {
return api.Errorf(api.CodeBadRequest, "%s must be at most %d bytes", field, max)
}
for i := 0; i < len(v); i++ {
if c := v[i]; c < 0x20 || c == 0x7f {
return api.Errorf(api.CodeBadRequest, "%s must not contain control characters", field)
}
}
return nil
}
// checkSitePath validates a path that names a document inside a deployment,
// such as index_file or not_found_file.
//
// It is checked here as well as at serve time because a value that cannot
// possibly resolve is a configuration mistake worth reporting at the moment it
// is made, rather than as a silent 404 on every request afterwards.
func checkSitePath(field, v string) error {
if v == "" {
return api.Errorf(api.CodeInvalidPath, "%s must not be empty", field)
}
if len(v) > maxSitePathLen {
return api.Errorf(api.CodeInvalidPath, "%s must be at most %d bytes", field, maxSitePathLen)
}
// fs.ValidPath rejects "..", absolute paths, empty segments and a trailing
// slash. It accepts ".", which is a directory rather than a document.
if !fs.ValidPath(v) || v == "." {
return api.Errorf(api.CodeInvalidPath,
"%s must be a relative slash-separated path with no . or .. segments", field)
}
if strings.ContainsRune(v, '\\') {
return api.Errorf(api.CodeInvalidPath, "%s must use forward slashes", field)
}
for i := 0; i < len(v); i++ {
if c := v[i]; c < 0x20 || c == 0x7f {
return api.Errorf(api.CodeInvalidPath, "%s must not contain control characters", field)
}
}
return nil
}
// checkHeaderValue validates a string that is emitted verbatim as a response
// header value.
//
// A CR or LF here would be response splitting: the project could append headers
// of its own choosing to every response it serves, and under path routing those
// responses share an origin with every other project. net/http replaces newlines
// with spaces on write, so this is defence in depth — but it is the layer that
// keeps the bad value out of the database in the first place, and it is the one
// that tells the operator they typed something wrong.
func checkHeaderValue(field, v string) error {
if len(v) > maxCacheControlLen {
return api.Errorf(api.CodeBadRequest, "%s must be at most %d bytes", field, maxCacheControlLen)
}
for i := 0; i < len(v); i++ {
c := v[i]
if c == '\t' {
continue
}
if c < 0x20 || c > 0x7e {
return api.Errorf(api.CodeBadRequest,
"%s must contain only printable ASCII (no newlines)", field)
}
}
return nil
}