Version 0.2.0

Changes:
- forbid to overwrite secrets of other users
- add some logs of success
- add validation on code (length 50)
- use same limiter for POST requests
This commit is contained in:
2026-08-17 21:13:15 +00:03
parent 6ee80f1c8f
commit 1ccf40396f
16 changed files with 184 additions and 38 deletions

View File

@@ -189,6 +189,14 @@ func (response SetValue401Response) VisitSetValueResponse(w http.ResponseWriter)
return nil
}
type SetValue403Response struct {
}
func (response SetValue403Response) VisitSetValueResponse(w http.ResponseWriter) error {
w.WriteHeader(403)
return nil
}
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
@@ -280,14 +288,14 @@ func (sh *strictHandler) SetValue(ctx *gin.Context) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/7STwW7bMAyGX8XgdjRiZ+3Jt26HIYcBwwL0EuSgSEzCwhVVkQ4QFH73QZKTLcuKbgV6",
"siH95E9+pJ7B8mNgj14FumcQu8dHk3+/ot6bfsAf+DSgaDoKkQNGJcwCyw7TV48BoQPRSH4H41hDxKeB",
"IjroVkW1rk8q3jygVRjr3/JLYC94bXBI1687FNnfLJZvbKH+V+scX79YwViDoB0i6XGZwBbTjRGyd4Pu",
"z8BTTD6Fc4q9aoAxZSC/5VwJaZ9uvrDDezP0Wt19XyRzjELsoYP5rJ21qXoO6E0g6OBm1s5uoIZgdJ/N",
"GxOoOcyb3YQmM+HCJpExSuwXDrrzfKC0jKKf2R0LNa/oc4gJoSebg5oHSVWcdij9fYy4hQ4+NL+WrJk2",
"rPlzvcZLthoHzAdlOXLtn9r2Heyn7cv+DsVGClp4ZkG15cG7RPW22F9qFv5genLVhKjaJEZZfHstLgk9",
"6ylpNj2NRF4dyfJ9R7J8y0hebFOUU9j/g5tfi7+RCPldxbGiKc5GdOiVTC8XDw261cUTW63HdeL8MwAA",
"//98/iCR6gQAAA==",
"H4sIAAAAAAAC/7RTTY/TMBD9K9bAMWpSulxyWzigSiAhKu2l6sGNp61XqcdrjwvVKv8djZ0WSlm+xJ5i",
"Oe/NG7838wgd7T05dByhfYTY7XCv8/Ed8p3uE37Ch4SR5coH8hjYYgZ0ZFC+e/3lPbot76B93VTAR4/Q",
"QuRg3RaGoYKAD8kGNNAuC2l1RtH6HjuGofpOLnpyEa/1DvJbDr9WKLCfSSz+z4uqP+0kl6uebGioIGKX",
"guXjQmwvPax1tN1tEu0xDuHkWziX2DF7GKSCdRvKnVju5c9bMninU8/q9uNcxDFESw5amE6aSSPdk0en",
"vYUWZpNmMoMKvOZdFq+1t/VhWm9Hp7JFVKwSozRbcnMD7TkuKE/GyG/IHIuJjtFliva+t10m1fdRujhN",
"mJxeBtxACy/qbyNYj/NX/zh8w6W3HBLmizIrufdXTfMM8uMwZn2DsQvWc/EzA9SGkjPi6k2Rv8TM3UH3",
"1qjRIrUWjzL45hpcCjriU9Eseook/jaSxfNGsviXSJ58ZmQS2t8bN70Gf7AxWrdVFJQdeV1Ag46t7mOh",
"za5psiqKPjs0an1U2hHvMKgUMVzsJrTLi61croaVRPM1AAD//whGvnU7BQAA",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@@ -6,8 +6,10 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.uber.org/zap"
"github.com/kanootoko/codevault/internal/db"
"github.com/kanootoko/codevault/internal/observability/logging"
"github.com/kanootoko/codevault/internal/observability/metrics"
)
@@ -22,7 +24,7 @@ func NewHandler(secrets *db.SecretsRepository, m metrics.ValueMetrics) *StrictHa
}
func (h *StrictHandler) GetValue(ctx context.Context, request GetValueRequestObject) (GetValueResponseObject, error) {
if request.Body == nil || request.Body.Code == "" {
if request.Body == nil || request.Body.Code == "" || len(request.Body.Code) > 50 {
return GetValue400Response{}, nil
}
@@ -35,21 +37,36 @@ func (h *StrictHandler) GetValue(ctx context.Context, request GetValueRequestObj
return nil, err
}
logger := logging.LoggerMustFromContext(ctx)
h.Metrics.GetSuccessTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("code", request.Body.Code)))
ip, _ := IPFromContext(ctx)
logger.Info("get value succeeded", zap.String("code", request.Body.Code), zap.String("ip", ip))
return GetValue200JSONResponse{Value: value}, nil
}
func (h *StrictHandler) SetValue(ctx context.Context, request SetValueRequestObject) (SetValueResponseObject, error) {
if request.Body == nil || request.Body.Code == "" || request.Body.Value == "" || len(request.Body.Value) > 10*1024*1024 {
if request.Body == nil || request.Body.Code == "" || len(request.Body.Code) > 50 ||
request.Body.Value == "" || len(request.Body.Value) > 10*1024*1024 {
h.Metrics.SetFailedTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", "bad_request")))
return SetValue400Response{}, nil
}
if err := h.Secrets.Set(ctx, request.Body.Code, request.Body.Value); err != nil {
login, _ := LoginFromContext(ctx)
ip, _ := IPFromContext(ctx)
logger := logging.LoggerMustFromContext(ctx)
if err := h.Secrets.Set(ctx, request.Body.Code, request.Body.Value, login); err != nil {
if errors.Is(err, db.ErrForbidden) {
h.Metrics.SetFailedTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", "forbidden")))
logger.Warn("set value forbidden", zap.String("code", request.Body.Code), zap.String("ip", ip), zap.String("user", login))
return SetValue403Response{}, nil
}
return nil, err
}
login, _ := LoginFromContext(ctx)
h.Metrics.SetSuccessTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("user", login)))
logger.Info("set value succeeded", zap.String("code", request.Body.Code), zap.String("ip", ip), zap.String("user", login))
return SetValue204Response{}, nil
}

View File

@@ -8,8 +8,10 @@ import (
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.uber.org/zap"
"github.com/kanootoko/codevault/internal/db"
"github.com/kanootoko/codevault/internal/observability/logging"
"github.com/kanootoko/codevault/internal/observability/metrics"
"github.com/kanootoko/codevault/internal/ratelimit"
)
@@ -17,6 +19,7 @@ import (
type contextKey string
const loginContextKey contextKey = "login"
const ipContextKey contextKey = "ip"
// LoginFromContext returns the Basic Auth login stashed by BasicAuthMiddleware,
// if any. Strict-server handlers only see context.Context, not *gin.Context, so
@@ -26,12 +29,33 @@ func LoginFromContext(ctx context.Context) (string, bool) {
return login, ok
}
// IPFromContext returns the client IP stashed by InstrumentMiddleware, if any.
// Strict-server handlers only see context.Context, not *gin.Context, so this is
// how they recover it.
func IPFromContext(ctx context.Context) (string, bool) {
ip, ok := ctx.Value(ipContextKey).(string)
return ip, ok
}
// BasicAuthMiddleware checks the request's HTTP Basic Auth credentials against
// the DB-backed user store.
func BasicAuthMiddleware(users *db.UsersRepository, vm metrics.ValueMetrics) gin.HandlerFunc {
// the DB-backed user store. It shares the same per-IP failure limiter as
// RateLimitMiddleware, so repeated bad credentials get throttled the same way
// repeated failed lookups do, instead of the caller being able to hammer the
// user store indefinitely.
func BasicAuthMiddleware(users *db.UsersRepository, limiter *ratelimit.Limiter, vm metrics.ValueMetrics) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !limiter.Allowed(ip) {
limiter.RecordFailure(ip)
vm.SetFailedTotal.Add(c.Request.Context(), 1, metric.WithAttributes(attribute.String("reason", "rate_limited")))
c.Header("WWW-Authenticate", `Basic realm="codevault"`)
c.AbortWithStatus(http.StatusUnauthorized)
return
}
login, password, ok := c.Request.BasicAuth()
if !ok || users.Verify(c.Request.Context(), login, password) != nil {
limiter.RecordFailure(ip)
vm.SetFailedTotal.Add(c.Request.Context(), 1, metric.WithAttributes(attribute.String("reason", "unauthorized")))
c.Header("WWW-Authenticate", `Basic realm="codevault"`)
c.AbortWithStatus(http.StatusUnauthorized)
@@ -64,9 +88,13 @@ func RateLimitMiddleware(limiter *ratelimit.Limiter, vm metrics.ValueMetrics) gi
}
}
// InstrumentMiddleware records request count and duration metrics per route.
func InstrumentMiddleware(route string, m metrics.HTTPMetrics) gin.HandlerFunc {
// InstrumentMiddleware records request count and duration metrics per route. Also injects logger to context.
func InstrumentMiddleware(route string, m metrics.HTTPMetrics, logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), ipContextKey, c.ClientIP())
ctx = logging.CtxWithLogger(ctx, logger)
c.Request = c.Request.WithContext(ctx)
start := time.Now()
c.Next()
duration := time.Since(start).Seconds()

View File

@@ -41,6 +41,8 @@ paths:
description: Invalid request body
'401':
description: Missing or invalid credentials
'403':
description: Code owned by another user
components:
securitySchemes:
basicAuth:
@@ -54,6 +56,7 @@ components:
properties:
code:
type: string
maxLength: 50
GetValueResponse:
type: object
required:
@@ -69,5 +72,6 @@ components:
properties:
code:
type: string
maxLength: 50
value:
type: string

View File

@@ -29,6 +29,9 @@ func RegisterSwaggerHandlers(router *gin.Engine) error {
router.GET("/api", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/api/docs/index.html")
})
router.GET("/docs", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/api/docs/index.html")
})
router.GET("/api/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/api/openapi.json")))
return nil