Concevoir un package d'erreurs sécurisé en Go : safe by default

Les erreurs en Go, c'est simple. Trop simple, parfois. Un fmt.Errorf("pq: no rows in result set") qui remonte jusqu'au handler HTTP, et voilà des détails SQL exposés dans une réponse 500. Pas besoin d'une faille exotique — juste un err.Error() au mauvais endroit.

Ce problème est systémique. Il ne vient pas d'un manque de discipline, mais d'une absence de frontière dans le type system. Rien dans l'interface error ne distingue ce qui est safe à exposer de ce qui ne l'est pas.

Le problème concret

Voici la fuite classique. Un handler qui sert directement err.Error() dans sa réponse JSON, et une couche repo qui wrappe les erreurs avec du contexte technique :

// handler HTTP
func (h *Handler) GetOrder(c echo.Context) error {
    order, err := h.service.FindByID(ctx, id)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{
            "error": err.Error(), // ← FUITE
        })
    }
    return c.JSON(http.StatusOK, order)
}

// couche repo
func (r *Repo) FindByID(ctx context.Context, id string) (*Order, error) {
    var o Order
    err := r.db.GetContext(ctx, &o, "SELECT * FROM orders WHERE id = $1", id)
    if err != nil {
        return nil, fmt.Errorf("orders.FindByID: %w", err) // wrapping standard
    }
    return &o, nil
}

Réponse reçue par le client :

{"error": "orders.FindByID: pq: no rows in result set"}

Le nom de la table, le driver PostgreSQL, et la nature de l'erreur SQL sont exposés publiquement. Rien de catastrophique isolément — mais c'est exactement le type d'information qu'un attaquant utilise pour affiner une injection ou cibler une surface d'attaque.

Ce que les solutions existantes apportent (et leurs limites)

Solution Approche Limite
cockroachdb/errors Package exhaustif, stack traces, wrapping riche ~15k lignes, 8 sous-packages. Disproportionné pour un microservice métier.
Pattern upspin (Rob Pike) Type Error avec Kind, Op, Err Pré-Go 1.13, pas de slog, et errors.Is() traverse toute la chaîne.
Pattern hashicorp Interface UserError avec GetMessage() Repose sur la discipline développeur, pas sur le type system.

La vraie question : peut-on concevoir un type où err.Error() retourne toujours quelque chose de safe, sans convention à respecter ?

Le design de safeerr

Cinq principes structurent le package :

  1. Safe by default : Error() retourne uniquement le message user-facing.
  2. Pas de Unwrap() : la chaîne d'introspection est intentionnellement rompue. errors.Is() ne traverse jamais la cause interne. Choix de sécurité.
  3. slog.LogValuer : les détails techniques sont exposés uniquement dans les logs structurés.
  4. Builder pattern immutable : WithMsg(), WrapCause() retournent toujours une nouvelle instance — les sentinelles ne sont jamais mutées.
  5. Mapping HTTP centralisé : un seul point mappe les ErrorKind vers les status HTTP.

Les sentinelles

var (
    ErrNotFound     = New("NOT_FOUND",     KindNotFound,     http.StatusNotFound,            "not found")
    ErrUnauthorized = New("UNAUTHORIZED",  KindUnauthorized, http.StatusUnauthorized,        "unauthorized")
    ErrInvalidInput = New("INVALID_INPUT", KindInvalidInput, http.StatusUnprocessableEntity, "invalid input")
    ErrInvalidState = New("INVALID_STATE", KindInvalidState, http.StatusConflict,            "invalid state")
    ErrInternal     = New("INTERNAL",      KindInternal,     http.StatusInternalServerError, "internal error")
)

Le builder pattern immutable

func (e *AppErr) WithMsg(msg string) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: e.cause}
}

func (e *AppErr) WrapCause(cause error, msg string) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: cause}
}

Chaque appel crée une nouvelle instance. Les sentinelles déclarées en var ne sont jamais modifiées — pas de risque de mutation partagée entre goroutines.

slog.LogValuer

func (e *AppErr) LogValue() slog.Value {
    attrs := []slog.Attr{
        slog.String("code", e.code),
        slog.String("msg", e.msg),
    }
    if e.cause != nil {
        attrs = append(attrs, slog.String("cause", e.cause.Error()))
    }
    return slog.GroupValue(attrs...)
}

Log résultant :

level=ERROR msg="order processing failed" err.code=NOT_FOUND err.msg="order not found" err.cause="pq: no rows in result set"

Les détails techniques — driver, requête, table — restent dans les logs. Jamais dans la réponse HTTP.

Mapping HTTP centralisé

func Error(c echo.Context, err error) error {
    var appErr *safeerr.AppErr
    if errors.As(err, &appErr) {
        return c.JSON(appErr.Status(), map[string]string{
            "error": appErr.Error(),
        })
    }
    slog.Error("unhandled error reaching handler", "err", err)
    return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
}

Un seul endroit dans le codebase sait comment traduire une erreur en réponse HTTP. Si une erreur non typée remonte jusqu'ici, elle est loggée et retourne un message générique.

Avant / après

Avant — les erreurs techniques remontent brutes jusqu'au handler :

// service — erreur technique remontée brute
func (s *Service) ProcessOrder(ctx context.Context, cmd ProcessOrderCmd) error {
    order, err := s.repo.FindOrder(ctx, cmd.OrderID)
    if err != nil {
        return fmt.Errorf("FindOrder: %w", err)
    }
    if order.ContractRef == "" {
        return errors.New("order has no contract reference")
    }
    return nil
}

Après — conversion explicite à la frontière service :

// service — conversion explicite à la frontière
func (s *Service) ProcessOrder(ctx context.Context, cmd ProcessOrderCmd) error {
    order, err := s.repo.FindOrder(ctx, cmd.OrderID)
    if err != nil {
        return safeerr.ErrNotFound.WrapCause(err, "order not found")
    }
    if order.ContractRef == "" {
        return safeerr.ErrInvalidState.WithMsg("order has no contract reference")
    }
    return nil
}

// handler
func (h *Handler) ProcessOrder(c echo.Context) error {
    if err := h.service.ProcessOrder(ctx, cmd); err != nil {
        return render.Error(c, err)
    }
    return c.JSON(http.StatusOK, nil)
}

Réponse HTTP : {"error": "order not found"} — la cause SQL reste dans les logs, jamais dans la réponse.

Le package complet (~130 lignes)

package safeerr

import "log/slog"

type ErrorKind int

const (
    KindNotFound ErrorKind = iota
    KindUnauthorized
    KindForbidden
    KindInvalidInput
    KindInvalidState
    KindConflict
    KindInternal
)

type AppErr struct {
    code   string
    kind   ErrorKind
    status int
    msg    string
    cause  error
}

func New(code string, kind ErrorKind, status int, msg string) *AppErr {
    return &AppErr{code: code, kind: kind, status: status, msg: msg}
}

func (e *AppErr) Error() string   { return e.msg }
func (e *AppErr) Code() string    { return e.code }
func (e *AppErr) Kind() ErrorKind { return e.kind }
func (e *AppErr) Status() int     { return e.status }
func (e *AppErr) Cause() error    { return e.cause }

func (e *AppErr) WithMsg(msg string) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: e.cause}
}

func (e *AppErr) WrapCause(cause error, msg string) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: msg, cause: cause}
}

func (e *AppErr) WithCause(cause error) *AppErr {
    return &AppErr{code: e.code, kind: e.kind, status: e.status, msg: e.msg, cause: cause}
}

func (e *AppErr) LogValue() slog.Value {
    attrs := []slog.Attr{
        slog.String("code", e.code),
        slog.String("msg", e.msg),
    }
    if e.cause != nil {
        attrs = append(attrs, slog.String("cause", e.cause.Error()))
    }
    return slog.GroupValue(attrs...)
}

Les trade-offs honnêtes

  • Pas de stack traces. AppErr ne capture pas la goroutine stack. Les logs structurés avec contexte métier compensent pour la majorité des cas, mais pour du debugging post-mortem approfondi c'est une limite réelle.
  • WrapCause peut leaker si mal utilisé. WrapCause(err, err.Error()) rompt la sécurité — le message user-facing devient le message technique. La discipline reste nécessaire sur ce point précis.
  • Le coût de conversion. Chaque erreur technique doit être convertie en AppErr à la frontière service. C'est une friction intentionnelle — sur un projet avec une centaine de points d'erreur, c'est du code à écrire et maintenir.
  • errors.Is() ne fonctionne plus sur la cause. Si une couche haute veut tester errors.Is(err, sql.ErrNoRows), elle ne peut pas. Acceptable si la taxonomie des sentinelles est suffisamment expressive, problématique sinon.

Conclusion

La vraie valeur de ce package n'est pas dans les 130 lignes. C'est dans la frontière qu'il rend explicite entre ce qui est safe à exposer et ce qui ne l'est pas. Cette frontière existait déjà dans toute application correctement conçue — mais elle reposait sur la discipline, sur des conventions documentées, sur des reviews attentives.

Déplacer cette contrainte dans le type system a un coût : la conversion explicite à chaque boundary. En échange : un err.Error() accidentel dans une réponse HTTP ne fuite jamais rien, indépendamment de qui a écrit le code.

Pour une librairie interne partagée entre plusieurs microservices d'une même stack, c'est un investissement qui se rembourse rapidement.

Commentaires (0)