Files
vikunja/pkg/routes/api/v2/errors.go
T
kolaente 7870ccdb01 fix(api/v2): strip internal error detail in NewError, not NewErrorWithContext
The 5xx sanitizer lived in the NewErrorWithContext override, but huma's
Error5xx* helpers (Error500InternalServerError and friends) call NewError
directly, so anything built through them kept the raw cause in the
problem+json `errors[]`. Huma's dispatch loop then writes an already-built
StatusError as-is, so the framework could not re-sanitize it either.

Move the strip into NewError so every 5xx passes through it by construction
and no future handler can reintroduce the leak. NewErrorWithContext is now
left at huma's default, which delegates to NewError — keeping both would log
the same cause twice.

4xx errors keep their details, including ErrorDetailer locations and the
Vikunja `code`/`i18n_params` fields. Huma's registration-time schema probe
calls NewError(0, ""), which is below the threshold and unaffected.
2026-07-28 19:26:15 +02:00

170 lines
6.4 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package apiv2
import (
"context"
"errors"
"net/http"
"strings"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/web"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
)
// authFromCtx retrieves the authed user from a Huma handler context,
// surfacing lookup failures as 401 instead of falling through to 500.
func authFromCtx(ctx context.Context) (web.Auth, error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
// The underlying error can carry internal adapter/config detail
// (e.g. a missing Echo context — a programming error, since the
// token middleware authenticates before the handler runs). Log it
// and return a generic 401 so nothing internal leaks to clients.
log.Errorf("v2: could not resolve auth from context: %s", err)
return nil, huma.Error401Unauthorized("invalid or missing authentication")
}
return a, nil
}
// translateDomainError maps a Vikunja domain error (web.HTTPErrorProcessor)
// onto Huma's status-error type so the response carries the right code
// and an RFC 9457 body. Errors without HTTP semantics fall through, which
// Huma treats as 500.
func translateDomainError(err error) error {
if err == nil {
return nil
}
var hp web.HTTPErrorProcessor
if errors.As(err, &hp) {
details := hp.HTTPError()
msg := details.Message
if msg == "" {
msg = err.Error()
}
se := huma.NewError(details.HTTPCode, msg)
// Preserve Vikunja's numeric domain error code (the value the
// error docs key off) on the problem+json body. v1 exposes it as
// `code`; without this v2 clients always read 0. I18nParams rides
// along the same way so v2 clients can localise the message like
// v1 clients do.
if vm, ok := se.(*vikunjaErrorModel); ok {
vm.Code = details.Code
vm.I18nParams = details.I18nParams
}
return se
}
// v2 maps validation failures to 422 (not v1's 412) so a govalidator failure
// looks identical to Huma's own schema validation. ValidationHTTPError isn't an
// HTTPErrorProcessor (the embedded field shadows the method), so it lands here.
var ve models.ValidationHTTPError
if errors.As(err, &ve) {
se := huma.NewError(http.StatusUnprocessableEntity, ve.Error(), invalidFieldDetails(ve.InvalidFields)...)
if vm, ok := se.(*vikunjaErrorModel); ok {
vm.Code = ve.GetCode()
}
return se
}
// Shared transport-agnostic cores (e.g. auth.RefreshSession) signal HTTP
// semantics with *echo.HTTPError. v1 lets echo's error handler render it;
// without this it would fall through as a 500 on v2.
var he *echo.HTTPError
if errors.As(err, &he) {
msg := he.Message
if msg == "" {
msg = http.StatusText(he.Code)
}
return huma.NewError(he.Code, msg)
}
return err
}
// invalidFieldDetails turns ValidationHTTPError's invalid_fields into RFC 9457
// error details. Entries come in two shapes — govalidator's "field: message" and
// model call sites' bare field names — and both must yield a Location.
func invalidFieldDetails(fields []string) []error {
details := make([]error, 0, len(fields))
for _, f := range fields {
name, msg, ok := strings.Cut(f, ": ")
if !ok {
msg = "Invalid data"
}
details = append(details, &huma.ErrorDetail{Location: "body." + name, Message: msg})
}
return details
}
// vikunjaErrorModel extends Huma's RFC 9457 body with Vikunja's numeric
// domain error code, preserving the v1 error-code contract on v2. Wired in
// as the global error type via the huma.NewError override in init().
type vikunjaErrorModel struct {
huma.ErrorModel
Code int `json:"code,omitempty" readOnly:"true" doc:"Vikunja numeric error code; see https://vikunja.io/docs/errors/"`
I18nParams map[string]string `json:"i18n_params,omitempty" readOnly:"true" doc:"Dynamic values referenced by the error message, keyed by translation placeholder name, for client-side localisation."`
}
func init() {
// Replace Huma's default error constructor so both the generated
// OpenAPI schema and runtime responses use vikunjaErrorModel. Huma
// derives the error-response schema from NewError(0, "") at register
// time and routes runtime errors through the same constructor, so the
// `code` field stays consistent between spec and wire.
huma.NewError = func(status int, msg string, errs ...error) huma.StatusError {
// Strip internal detail from server errors. The humaecho adapter writes
// responses itself, bypassing Vikunja's CreateHTTPErrorHandler which for
// v1 returns a generic 500 — so without this a raw DB/driver error (hosts,
// ports, credentials, schema names) leaks into problem+json `errors[]`,
// including on public endpoints like /health. This must live in NewError
// rather than NewErrorWithContext: the huma.Error5xx* helpers call NewError
// directly, and huma writes an already-built StatusError as-is, so NewError
// is the only chokepoint every 5xx passes through.
if status >= 500 {
for _, e := range errs {
if e != nil {
log.Errorf("v2: internal server error: %s", e)
}
}
errs = nil
}
details := make([]*huma.ErrorDetail, 0, len(errs))
for _, e := range errs {
if e == nil {
continue
}
if d, ok := e.(huma.ErrorDetailer); ok {
details = append(details, d.ErrorDetail())
} else {
details = append(details, &huma.ErrorDetail{Message: e.Error()})
}
}
return &vikunjaErrorModel{ErrorModel: huma.ErrorModel{
Status: status,
Title: http.StatusText(status),
Detail: msg,
Errors: details,
}}
}
// NewErrorWithContext is deliberately left at huma's default, which delegates
// to NewError above — overriding it too would log the same cause twice.
}