Files
zeekay c10cd537a6 the deployment answers what is asked of the process
The rule and the counters moved onto the Base the process serves from; the client
KEY did not, and that turned the previous fix into a cross-tenant denial of
service. checkRateLimit keys on RealIP, and RealIP asked e.App which proxy
headers to believe — the tenant's Base by then, whose TrustedProxy is empty and
can never be anything else, since only a superuser writes settings and a tenant's
Base has none. Behind an ingress every authenticated request fell back to the
socket peer, which is the ingress for all of them: one bucket for the estate, so
one tenant spending its budget locked out every other. Measured: alpha spends a
3-per-minute budget and beta, arriving from another address, is refused 3 of 3.

Rather than teach the limiter a second way to find that Base, core.RequestEvent
now names it — Deployment() — and everything that answers a question about the
process rather than about a tenant's data reads it there. RealIP, the rate limit
and its counters, the batch limits, and the activity log.

The log had the same bug and worse consequences. logRequest read e.App, so an
authenticated request wrote its audit row into the tenant's own file and the
operator's log held only traffic that never authenticated. It also wrote
RequestURI() whole, and a key arrives in ?key= on a request that carries no
Authorization header, so a live secret key sat in _logs in plaintext for
Logs.MaxDays, was served by GET /v1/logs, and went into every backup. The query
is redacted for the parameters this deployment reads a credential out of. And a
keyed request sets no e.Auth, so every action a service key took was attributed
to nobody; the subject is what both doors publish, so a subject with no record is
exactly a key.

Batch read Enabled off the tenant too, and a freshly opened Base answers false,
so an operator who turned batching on had it silently off for everyone with a
token.

RemoteIP reports no address where it cannot read one, instead of the zero Addr,
whose expanded spelling is the literal string "invalid IP". A ZAP-forwarded
request carries no socket peer, so the whole leg was counted as that one client.
2026-08-11 16:08:08 -07:00

408 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package router
import (
"encoding/json"
"encoding/xml"
"errors"
"io"
"io/fs"
"net"
"net/http"
"net/netip"
"path/filepath"
"strings"
"github.com/hanzoai/base/tools/filesystem"
"github.com/hanzoai/base/tools/hook"
"github.com/hanzoai/base/tools/picker"
"github.com/hanzoai/base/tools/store"
)
var ErrUnsupportedContentType = NewBadRequestError("Unsupported Content-Type", nil)
var ErrInvalidRedirectStatusCode = NewInternalServerError("Invalid redirect status code", nil)
var ErrFileNotFound = NewNotFoundError("File not found", nil)
const IndexPage = "index.html"
// Event specifies based Route handler event that is usually intended
// to be embedded as part of a custom event struct.
//
// NB! It is expected that the Response and Request fields are always set.
type Event struct {
Response http.ResponseWriter
Request *http.Request
hook.Event
data store.Store[string, any]
}
// RWUnwrapper specifies that an http.ResponseWriter could be "unwrapped"
// (usually used with [http.ResponseController]).
type RWUnwrapper interface {
Unwrap() http.ResponseWriter
}
// Written reports whether the current response has already been written.
//
// This method always returns false if e.ResponseWritter doesn't implement the WriteTracker interface
// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one).
func (e *Event) Written() bool {
written, _ := getWritten(e.Response)
return written
}
// Status reports the status code of the current response.
//
// This method always returns 0 if e.Response doesn't implement the StatusTracker interface
// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one).
func (e *Event) Status() int {
status, _ := getStatus(e.Response)
return status
}
// Flush flushes buffered data to the current response.
//
// Returns [http.ErrNotSupported] if e.Response doesn't implement the [http.Flusher] interface
// (all router package handlers receives a ResponseWritter that implements it unless explicitly replaced with a custom one).
func (e *Event) Flush() error {
return http.NewResponseController(e.Response).Flush()
}
// IsTLS reports whether the connection on which the request was received is TLS.
func (e *Event) IsTLS() bool {
return e.Request.TLS != nil
}
// SetCookie is an alias for [http.SetCookie].
//
// SetCookie adds a Set-Cookie header to the current response's headers.
// The provided cookie must have a valid Name.
// Invalid cookies may be silently dropped.
func (e *Event) SetCookie(cookie *http.Cookie) {
http.SetCookie(e.Response, cookie)
}
// RemoteIP returns the IP address of the client that sent the request.
//
// IPv6 addresses are returned expanded.
// For example, "2001:db8::1" becomes "2001:0db8:0000:0000:0000:0000:0000:0001".
//
// Note that if you are behind reverse proxy(ies), this method returns
// the IP of the last connecting proxy.
//
// An address that does not parse is reported as no address rather than as the
// zero Addr, whose expanded spelling is the literal "invalid IP". A transport
// that carries no socket peer — ZAP, which arrives already terminated — gave
// every one of its requests that same string, so anything keyed on the client
// counted the whole leg as one client.
func (e *Event) RemoteIP() string {
ip, _, _ := net.SplitHostPort(e.Request.RemoteAddr)
parsed, err := netip.ParseAddr(ip)
if err != nil {
return ""
}
return parsed.StringExpanded()
}
// FindUploadedFiles extracts all form files of "key" from a http request
// and returns a slice with filesystem.File instances (if any).
func (e *Event) FindUploadedFiles(key string) ([]*filesystem.File, error) {
if e.Request.MultipartForm == nil {
err := e.Request.ParseMultipartForm(DefaultMaxMemory)
if err != nil {
return nil, err
}
}
if e.Request.MultipartForm == nil || e.Request.MultipartForm.File == nil || len(e.Request.MultipartForm.File[key]) == 0 {
return nil, http.ErrMissingFile
}
result := make([]*filesystem.File, 0, len(e.Request.MultipartForm.File[key]))
for _, fh := range e.Request.MultipartForm.File[key] {
file, err := filesystem.NewFileFromMultipart(fh)
if err != nil {
return nil, err
}
result = append(result, file)
}
return result, nil
}
// Store
// -------------------------------------------------------------------
// Get retrieves single value from the current event data store.
func (e *Event) Get(key string) any {
return e.data.Get(key)
}
// GetAll returns a copy of the current event data store.
func (e *Event) GetAll() map[string]any {
return e.data.GetAll()
}
// Set saves single value into the current event data store.
func (e *Event) Set(key string, value any) {
e.data.Set(key, value)
}
// SetAll saves all items from m into the current event data store.
func (e *Event) SetAll(m map[string]any) {
for k, v := range m {
e.Set(k, v)
}
}
// Response writers
// -------------------------------------------------------------------
const headerContentType = "Content-Type"
func (e *Event) setResponseHeaderIfEmpty(key, value string) {
header := e.Response.Header()
if header.Get(key) == "" {
header.Set(key, value)
}
}
// String writes a plain string response.
func (e *Event) String(status int, data string) error {
e.setResponseHeaderIfEmpty(headerContentType, "text/plain; charset=utf-8")
e.Response.WriteHeader(status)
_, err := e.Response.Write([]byte(data))
return err
}
// HTML writes an HTML response.
func (e *Event) HTML(status int, data string) error {
e.setResponseHeaderIfEmpty(headerContentType, "text/html; charset=utf-8")
e.Response.WriteHeader(status)
_, err := e.Response.Write([]byte(data))
return err
}
const jsonFieldsParam = "fields"
// JSON writes a JSON response.
//
// It also provides a generic response data fields picker if the "fields" query parameter is set.
// For example, if you are requesting `?fields=a,b` for `e.JSON(200, map[string]int{ "a":1, "b":2, "c":3 })`,
// it should result in a JSON response like: `{"a":1, "b": 2}`.
func (e *Event) JSON(status int, data any) error {
e.setResponseHeaderIfEmpty(headerContentType, "application/json")
e.Response.WriteHeader(status)
rawFields := e.Request.URL.Query().Get(jsonFieldsParam)
// error response or no fields to pick
if rawFields == "" || status < 200 || status > 299 {
return json.NewEncoder(e.Response).Encode(data)
}
// pick only the requested fields
modified, err := picker.Pick(data, rawFields)
if err != nil {
return err
}
return json.NewEncoder(e.Response).Encode(modified)
}
// XML writes an XML response.
// It automatically prepends the generic [xml.Header] string to the response.
func (e *Event) XML(status int, data any) error {
e.setResponseHeaderIfEmpty(headerContentType, "application/xml; charset=utf-8")
e.Response.WriteHeader(status)
if _, err := e.Response.Write([]byte(xml.Header)); err != nil {
return err
}
return xml.NewEncoder(e.Response).Encode(data)
}
// Stream streams the specified reader into the response.
func (e *Event) Stream(status int, contentType string, reader io.Reader) error {
e.Response.Header().Set(headerContentType, contentType)
e.Response.WriteHeader(status)
_, err := io.Copy(e.Response, reader)
return err
}
// Blob writes a blob (bytes slice) response.
func (e *Event) Blob(status int, contentType string, b []byte) error {
e.setResponseHeaderIfEmpty(headerContentType, contentType)
e.Response.WriteHeader(status)
_, err := e.Response.Write(b)
return err
}
// FileFS serves the specified filename from fsys.
//
// It is similar to [echo.FileFS] for consistency with earlier versions.
func (e *Event) FileFS(fsys fs.FS, filename string) error {
f, err := fsys.Open(filename)
if err != nil {
return ErrFileNotFound
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return err
}
// if it is a directory try to open its index.html file
if fi.IsDir() {
filename = filepath.ToSlash(filepath.Join(filename, IndexPage))
f, err = fsys.Open(filename)
if err != nil {
return ErrFileNotFound
}
defer f.Close()
fi, err = f.Stat()
if err != nil {
return err
}
}
ff, ok := f.(io.ReadSeeker)
if !ok {
return errors.New("[FileFS] file does not implement io.ReadSeeker")
}
http.ServeContent(e.Response, e.Request, fi.Name(), fi.ModTime(), ff)
return nil
}
// NoContent writes a response with no body (ex. 204).
func (e *Event) NoContent(status int) error {
e.Response.WriteHeader(status)
return nil
}
// Redirect writes a redirect response to the specified url.
// The status code must be in between 300 – 399 range.
func (e *Event) Redirect(status int, url string) error {
if status < 300 || status > 399 {
return ErrInvalidRedirectStatusCode
}
e.Response.Header().Set("Location", url)
e.Response.WriteHeader(status)
return nil
}
// ApiError helpers
// -------------------------------------------------------------------
func (e *Event) Error(status int, message string, errData any) *ApiError {
return NewApiError(status, message, errData)
}
func (e *Event) BadRequestError(message string, errData any) *ApiError {
return NewBadRequestError(message, errData)
}
func (e *Event) NotFoundError(message string, errData any) *ApiError {
return NewNotFoundError(message, errData)
}
func (e *Event) ForbiddenError(message string, errData any) *ApiError {
return NewForbiddenError(message, errData)
}
func (e *Event) UnauthorizedError(message string, errData any) *ApiError {
return NewUnauthorizedError(message, errData)
}
func (e *Event) TooManyRequestsError(message string, errData any) *ApiError {
return NewTooManyRequestsError(message, errData)
}
func (e *Event) InternalServerError(message string, errData any) *ApiError {
return NewInternalServerError(message, errData)
}
// Binders
// -------------------------------------------------------------------
const DefaultMaxMemory = 16 << 20 // 16mb
// BindBody unmarshal the request body into the provided dst.
//
// dst must be either a struct pointer or map[string]any.
//
// The rules how the body will be scanned depends on the request Content-Type.
//
// Currently the following Content-Types are supported:
// - application/json
// - text/xml, application/xml
// - multipart/form-data, application/x-www-form-urlencoded
//
// Respectively the following struct tags are supported (again, which one will be used depends on the Content-Type):
// - "json" (json body)- uses the builtin Go json package for unmarshaling.
// - "xml" (xml body) - uses the builtin Go xml package for unmarshaling.
// - "form" (form data) - utilizes the custom [router.UnmarshalRequestData] method.
//
// NB! When dst is a struct make sure that it doesn't have public fields
// that shouldn't be bindable and it is advisible such fields to be unexported
// or have a separate struct just for the binding. For example:
//
// data := struct{
// somethingPrivate string
//
// Title string `json:"title" form:"title"`
// Total int `json:"total" form:"total"`
// }
// err := e.BindBody(&data)
func (e *Event) BindBody(dst any) error {
if e.Request.ContentLength == 0 {
return nil
}
contentType := e.Request.Header.Get(headerContentType)
if strings.HasPrefix(contentType, "application/json") {
dec := json.NewDecoder(e.Request.Body)
err := dec.Decode(dst)
if err == nil {
// manually call Reread because single call of json.Decoder.Decode()
// doesn't ensure that the entire body is a valid json string
// and it is not guaranteed that it will reach EOF to trigger the reread reset
// (ex. in case of trailing spaces or invalid trailing parts like: `{"test":1},something`)
if body, ok := e.Request.Body.(Rereader); ok {
body.Reread()
}
}
return err
}
if strings.HasPrefix(contentType, "multipart/form-data") {
if err := e.Request.ParseMultipartForm(DefaultMaxMemory); err != nil {
return err
}
return UnmarshalRequestData(e.Request.Form, dst, "", "")
}
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
if err := e.Request.ParseForm(); err != nil {
return err
}
return UnmarshalRequestData(e.Request.Form, dst, "", "")
}
if strings.HasPrefix(contentType, "text/xml") ||
strings.HasPrefix(contentType, "application/xml") {
return xml.NewDecoder(e.Request.Body).Decode(dst)
}
return ErrUnsupportedContentType
}