Remediate six confirmed security issues: deployment-only config keys, bounded provider responses, short-lived registration sessions, private init file mode, FedWiki workflow authorization, and switch preview gates. - Add DeploymentOnly config key declaration; refuse runtime overrides for keys that decide where secrets are sent - Create httplimit package; bound all provider response reads at 8 MiB - Set fifteen-minute deadline on /register sessions - Write mc-config.yaml with 0600 permissions - Derive FedWiki workflow IDs from site IDs; re-authorize sites before mutating activities - Apply switch authorization gates to the proration preview
79 lines
2.6 KiB
Go
79 lines
2.6 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
// Package httplimit reads a response body from a configured provider within
|
|
// a byte bound. Every outbound client (FarmManager, Discourse, the Temporal
|
|
// OAuth token endpoint, and any client added later) reads through here, so
|
|
// a provider, or whoever stands where its URL points, cannot grow this
|
|
// process's heap without limit (security-audit-remediation-3 design D5).
|
|
//
|
|
// A bounded response is either complete or refused, never silently
|
|
// truncated: a truncated JSON document fails later with a worse message,
|
|
// and a truncated error body would misquote the provider.
|
|
package httplimit
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
)
|
|
|
|
// DefaultResponseBytes is the bound a client uses unless it knows its
|
|
// response is smaller. 8 MiB: the largest legitimate response today is
|
|
// FarmManager's site list, a few hundred bytes per site.
|
|
const DefaultResponseBytes int64 = 8 << 20
|
|
|
|
// TooLargeError reports a response that exceeded its bound. Host names the
|
|
// provider the response came from, so an operator reading the log knows
|
|
// which bound to raise, or which provider to distrust.
|
|
type TooLargeError struct {
|
|
Bound int64
|
|
Host string
|
|
}
|
|
|
|
func (e *TooLargeError) Error() string {
|
|
return fmt.Sprintf("response from %s exceeds %d bytes", e.Host, e.Bound)
|
|
}
|
|
|
|
// ReadResponse reads resp's body whole when it fits within max bytes and
|
|
// closes it either way. A body longer than max returns *TooLargeError and
|
|
// no bytes; reading stops at max+1, so the process never holds more than
|
|
// the bound for one response.
|
|
func ReadResponse(resp *http.Response, max int64) ([]byte, error) {
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, max+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(body)) > max {
|
|
return nil, &TooLargeError{Bound: max, Host: hostOf(resp)}
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// DecodeResponse decodes resp's JSON body into v within the same bound
|
|
// ReadResponse applies, and closes the body. Beyond the bound it returns
|
|
// *TooLargeError and leaves v as the decoder left it.
|
|
func DecodeResponse(resp *http.Response, max int64, v any) error {
|
|
body, err := ReadResponse(resp, max)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(body, v)
|
|
}
|
|
|
|
// IsTooLarge reports whether err is, or wraps, a TooLargeError.
|
|
func IsTooLarge(err error) bool {
|
|
var tl *TooLargeError
|
|
return errors.As(err, &tl)
|
|
}
|
|
|
|
func hostOf(resp *http.Response) string {
|
|
if resp.Request != nil && resp.Request.URL != nil {
|
|
return resp.Request.URL.Host
|
|
}
|
|
return "provider"
|
|
}
|