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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
code TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
user TEXT REFERENCES users(login) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("secret not found")
|
||||
var ErrForbidden = errors.New("code owned by another user")
|
||||
|
||||
type SecretsRepository struct {
|
||||
db *sql.DB
|
||||
@@ -29,14 +30,35 @@ func (r *SecretsRepository) Get(ctx context.Context, code string) (string, error
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *SecretsRepository) Set(ctx context.Context, code, value string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO secrets (code, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(code) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||
`, code, value)
|
||||
// Set stores value under code, owned by user. If code already exists and is
|
||||
// owned by a different user, it returns ErrForbidden and leaves the stored
|
||||
// value untouched.
|
||||
func (r *SecretsRepository) Set(ctx context.Context, code, value, user string) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var owner string
|
||||
err = tx.QueryRowContext(ctx, "SELECT user FROM secrets WHERE code = ?", code).Scan(&owner)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("could not check secret owner: %w", err)
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) && owner != user {
|
||||
return ErrForbidden
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO secrets (code, value, user, created_at, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(code) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||
`, code, value, user); err != nil {
|
||||
return fmt.Errorf("could not set secret: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("could not commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
53
internal/db/secrets_test.go
Normal file
53
internal/db/secrets_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kanootoko/codevault/internal/db"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
sqlDB, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("could not open test db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
return sqlDB
|
||||
}
|
||||
|
||||
func TestSecretsRepository_SetOwnership(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
secrets := db.NewSecretsRepository(openTestDB(t))
|
||||
|
||||
if err := secrets.Set(ctx, "code1", "value1", "alice"); err != nil {
|
||||
t.Fatalf("alice's initial set failed: %v", err)
|
||||
}
|
||||
|
||||
if err := secrets.Set(ctx, "code1", "value2", "alice"); err != nil {
|
||||
t.Fatalf("alice's overwrite failed: %v", err)
|
||||
}
|
||||
value, err := secrets.Get(ctx, "code1")
|
||||
if err != nil {
|
||||
t.Fatalf("get failed: %v", err)
|
||||
}
|
||||
if value != "value2" {
|
||||
t.Fatalf("expected value2, got %q", value)
|
||||
}
|
||||
|
||||
err = secrets.Set(ctx, "code1", "value3", "bob")
|
||||
if !errors.Is(err, db.ErrForbidden) {
|
||||
t.Fatalf("expected ErrForbidden for bob, got %v", err)
|
||||
}
|
||||
value, err = secrets.Get(ctx, "code1")
|
||||
if err != nil {
|
||||
t.Fatalf("get failed: %v", err)
|
||||
}
|
||||
if value != "value2" {
|
||||
t.Fatalf("expected value to remain value2 after forbidden write, got %q", value)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func New(
|
||||
limiter *ratelimit.Limiter,
|
||||
m metrics.Metrics,
|
||||
trustedProxies []string,
|
||||
logger *zap.Logger,
|
||||
) (*gin.Engine, error) {
|
||||
router := gin.New()
|
||||
// Strict-server handlers only see context.Context (via *gin.Context, which
|
||||
@@ -54,13 +55,13 @@ func New(
|
||||
wrapper := api.ServerInterfaceWrapper{Handler: strictHandler}
|
||||
|
||||
router.POST("/api/v1/getValue",
|
||||
api.InstrumentMiddleware("getValue", m.HTTP),
|
||||
api.InstrumentMiddleware("getValue", m.HTTP, logger),
|
||||
api.RateLimitMiddleware(limiter, m.Value),
|
||||
wrapper.GetValue,
|
||||
)
|
||||
router.POST("/api/v1/setValue",
|
||||
api.InstrumentMiddleware("setValue", m.HTTP),
|
||||
api.BasicAuthMiddleware(users, m.Value),
|
||||
api.InstrumentMiddleware("setValue", m.HTTP, logger),
|
||||
api.BasicAuthMiddleware(users, limiter, m.Value),
|
||||
wrapper.SetValue,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ func RunServer(ctx context.Context, appConfig config.Config) error {
|
||||
|
||||
limiter := ratelimit.NewLimiter(appConfig.RateLimit.MaxFailures, appConfig.RateLimit.Window)
|
||||
|
||||
router, err := httpserver.New(secrets, users, limiter, appMetrics, appConfig.Server.TrustedProxies)
|
||||
router, err := httpserver.New(secrets, users, limiter, appMetrics, appConfig.Server.TrustedProxies, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error on building http router: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package internal
|
||||
|
||||
const Version = "0.1.0"
|
||||
const Version = "0.2.0"
|
||||
const PackageName = "codevault"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>CodeVault - Get value</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 480px; margin: 4rem auto; }
|
||||
body { font-family: sans-serif; max-width: 880px; margin: 4rem auto; }
|
||||
input, button { font-size: 1rem; padding: 0.4rem; }
|
||||
input { width: 100%; box-sizing: border-box; margin-bottom: 0.75rem; }
|
||||
#result { margin-top: 1rem; padding: 0.75rem; border-radius: 4px; white-space: pre-wrap; word-break: break-all; }
|
||||
@@ -14,7 +14,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<h1>Get value by code</h1>
|
||||
<input id="code" type="text" placeholder="Access code" autofocus>
|
||||
<input id="code" type="text" placeholder="Access code" maxlength="50" autofocus>
|
||||
<label style="display:block; margin-bottom:0.75rem; font-size:0.9rem;">
|
||||
<input id="formatted" type="checkbox" style="width:auto; margin:0 0.4rem 0 0; vertical-align:middle;">
|
||||
Get with HTML formatting if present
|
||||
</label>
|
||||
<button id="submit">Get value</button>
|
||||
<div id="result"></div>
|
||||
<p><a href="/set">Set a value instead</a></p>
|
||||
@@ -34,7 +38,11 @@ document.getElementById('submit').addEventListener('click', async () => {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
result.className = 'ok';
|
||||
result.textContent = data.value;
|
||||
if (document.getElementById('formatted').checked) {
|
||||
result.innerHTML = data.value;
|
||||
} else {
|
||||
result.textContent = data.value;
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Not found';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>CodeVault - Set value</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 480px; margin: 4rem auto; }
|
||||
body { font-family: sans-serif; max-width: 880px; margin: 4rem auto; }
|
||||
input, button, textarea { font-size: 1rem; padding: 0.4rem; }
|
||||
input, textarea { width: 100%; box-sizing: border-box; margin-bottom: 0.75rem; }
|
||||
#result { margin-top: 1rem; padding: 0.75rem; border-radius: 4px; white-space: pre-wrap; word-break: break-all; }
|
||||
@@ -16,7 +16,7 @@
|
||||
<h1>Set value for a code</h1>
|
||||
<input id="login" type="text" placeholder="Login" autocomplete="username">
|
||||
<input id="password" type="password" placeholder="Password" autocomplete="current-password">
|
||||
<input id="code" type="text" placeholder="Access code">
|
||||
<input id="code" type="text" placeholder="Access code" maxlength="50">
|
||||
<textarea id="value" rows="4" placeholder="Value"></textarea>
|
||||
<button id="submit">Save</button>
|
||||
<div id="result"></div>
|
||||
@@ -46,6 +46,9 @@ document.getElementById('submit').addEventListener('click', async () => {
|
||||
} else if (response.status === 401) {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Invalid login or password';
|
||||
} else if (response.status === 403) {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Code is owned by another user';
|
||||
} else if (response.status === 400) {
|
||||
result.className = 'err';
|
||||
result.textContent = 'Code and value are required';
|
||||
|
||||
Reference in New Issue
Block a user