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 }