v0.1.17: security hardening — 18/20 vulnerabilities fixed
Phase 1 (Critical): - #1 JWT auth (done in v0.1.16) - #2 Batch message size validation in send_message_batch.go - #10 RLock in GetQueueUrlV1 (data race fix) Phase 2 (AWS-compatible limits): - #3 QueueName validation: max 80 chars, [a-zA-Z0-9_-](.fifo)? - #4 WaitTimeSeconds clamped to 0-20 - #5 ReceiveMessageWaitTimeSeconds clamped to 0-20 - #6 DelaySeconds clamped to 0-900 - #7 VisibilityTimeout clamped to 0-43200 - #8 MaxNumberOfMessages clamped to 1-10 - #9 Message attributes limited to 10 per message - #15 BatchEntryId length validated (max 80) - #16 DeduplicationID length validated (max 128) - #17 GroupID length validated (max 128) Phase 3 (Per-tenant resource limits): - #11 Max messages per queue (120K standard, 20K FIFO) - #12 Global tenant limit (1000) - #3.5 HTTP request body size limit (1MB via MaxBytesReader) Phase 4 (Stability): - #14 Duplicates map cleanup (already in PeriodicTasks) - #13 FIFO group lock timeout (already in visibility timeout reset) - #18 Redis size guard: skip save if >50MB Skipped (Low, no real risk): - #19 {account} URL param (informational only, not used for access) - #20 ReceiptHandle format (self-validating UUID#UUID) New file: app/gosqs/validation.go — centralized AWS SQS limits and validators
This commit is contained in:
@@ -28,8 +28,11 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
|
||||
}
|
||||
|
||||
// Ловушка #8: передаём queueName (не key) в HasFIFOQueueName — иначе .fifo не определится
|
||||
// Валидация имени очереди по AWS правилам: макс 80 chars, [a-zA-Z0-9_-], опц. .fifo
|
||||
queueName := requestBody.QueueName
|
||||
if err := ValidateQueueName(queueName); err != nil {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
queueUrl := tenantQueueURL(t, queueName)
|
||||
queueArn := tenantQueueARN(t, queueName)
|
||||
|
||||
@@ -27,12 +27,14 @@ return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
|
||||
queueName := requestBody.QueueName
|
||||
key := tenantQueueKey(t.AccessKey, queueName)
|
||||
|
||||
if _, ok := models.SyncQueues.Queues[key]; !ok {
|
||||
// Fix #10: RLock перед чтением SyncQueues — иначе data race
|
||||
models.SyncQueues.RLock()
|
||||
queue, ok := models.SyncQueues.Queues[key]
|
||||
models.SyncQueues.RUnlock()
|
||||
if !ok {
|
||||
log.Errorf("Get Queue URL: %s, queue does not exist for tenant %s", queueName, t.ID)
|
||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||
}
|
||||
|
||||
queue := models.SyncQueues.Queues[key]
|
||||
log.Debug("Get Queue URL:", queue.Name)
|
||||
|
||||
respStruct := models.GetQueueUrlResponse{
|
||||
|
||||
@@ -14,23 +14,21 @@ import (
|
||||
// - attr.Policy
|
||||
// - attr.RedriveAllowPolicy
|
||||
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes) error {
|
||||
// FIXME - are there better places to put these bottom-limit validations?
|
||||
// AWS-совместимые лимиты: clamp значений к допустимым диапазонам
|
||||
if attr.DelaySeconds >= 0 {
|
||||
q.DelaySeconds = attr.DelaySeconds.Int()
|
||||
q.DelaySeconds = ClampInt(attr.DelaySeconds.Int(), 0, MaxDelaySeconds)
|
||||
}
|
||||
if attr.MaximumMessageSize >= 0 {
|
||||
q.MaximumMessageSize = attr.MaximumMessageSize.Int()
|
||||
q.MaximumMessageSize = ClampInt(attr.MaximumMessageSize.Int(), MinMessageSizeLimit, MaxMessageSizeLimit)
|
||||
}
|
||||
// TODO - bottom limit should be the AWS limits
|
||||
// The following 2 don't support zero values
|
||||
if attr.MessageRetentionPeriod > 0 {
|
||||
q.MessageRetentionPeriod = attr.MessageRetentionPeriod.Int()
|
||||
q.MessageRetentionPeriod = ClampInt(attr.MessageRetentionPeriod.Int(), MinMessageRetentionPeriod, MaxMessageRetentionPeriod)
|
||||
}
|
||||
if attr.ReceiveMessageWaitTimeSeconds > 0 {
|
||||
q.ReceiveMessageWaitTimeSeconds = attr.ReceiveMessageWaitTimeSeconds.Int()
|
||||
q.ReceiveMessageWaitTimeSeconds = ClampInt(attr.ReceiveMessageWaitTimeSeconds.Int(), 0, MaxReceiveMessageWaitTimeSeconds)
|
||||
}
|
||||
if attr.VisibilityTimeout >= 0 {
|
||||
q.VisibilityTimeout = attr.VisibilityTimeout.Int()
|
||||
q.VisibilityTimeout = ClampInt(attr.VisibilityTimeout.Int(), 0, MaxVisibilityTimeout)
|
||||
}
|
||||
if attr.RedrivePolicy != (models.RedrivePolicy{}) {
|
||||
arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":")
|
||||
|
||||
@@ -35,6 +35,8 @@ maxNumberOfMessages := requestBody.MaxNumberOfMessages
|
||||
if maxNumberOfMessages == 0 {
|
||||
maxNumberOfMessages = 1
|
||||
}
|
||||
// Fix #8: clamp MaxNumberOfMessages к AWS лимиту 1–10
|
||||
maxNumberOfMessages = ClampInt(maxNumberOfMessages, MinNumberOfMessagesLimit, MaxNumberOfMessagesLimit)
|
||||
|
||||
queueName := ""
|
||||
if requestBody.QueueUrl == "" {
|
||||
@@ -60,6 +62,8 @@ models.SyncQueues.RLock()
|
||||
waitTimeSeconds = models.SyncQueues.Queues[key].ReceiveMessageWaitTimeSeconds
|
||||
models.SyncQueues.RUnlock()
|
||||
}
|
||||
// Fix #4: clamp WaitTimeSeconds к AWS лимиту 0–20
|
||||
waitTimeSeconds = ClampInt(waitTimeSeconds, 0, MaxReceiveMessageWaitTimeSeconds)
|
||||
|
||||
// Long polling: ждём появления сообщения до waitTimeSeconds*10 итераций по 100ms
|
||||
loops := waitTimeSeconds * 10
|
||||
|
||||
@@ -38,6 +38,18 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
messageGroupID := requestBody.MessageGroupId
|
||||
messageDeduplicationID := requestBody.MessageDeduplicationId
|
||||
|
||||
// Валидация DeduplicationID и GroupID (макс 128 chars по AWS)
|
||||
if err := ValidateDeduplicationID(messageDeduplicationID); err != nil {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
if err := ValidateGroupID(messageGroupID); err != nil {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
// Валидация количества message attributes (макс 10)
|
||||
if len(requestBody.MessageAttributes) > MaxMessageAttributes {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
|
||||
queueUrl := getQueueFromPath(requestBody.QueueUrl, req.URL.String())
|
||||
queueName := ""
|
||||
if queueUrl == "" {
|
||||
@@ -60,6 +72,15 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||
return utils.CreateErrorResponseV1("MessageTooBig", true)
|
||||
}
|
||||
|
||||
// Fix #11: лимит сообщений в очереди — защита от OOM
|
||||
models.SyncQueues.RLock()
|
||||
currentMsgCount := len(models.SyncQueues.Queues[key].Messages)
|
||||
queueIsFIFO := models.SyncQueues.Queues[key].IsFIFO
|
||||
models.SyncQueues.RUnlock()
|
||||
if currentMsgCount >= MaxMessagesForQueue(queueIsFIFO) {
|
||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||
}
|
||||
|
||||
delaySecs := models.SyncQueues.Queues[key].DelaySeconds
|
||||
if requestBody.DelaySeconds != 0 {
|
||||
delaySecs = requestBody.DelaySeconds
|
||||
|
||||
@@ -59,11 +59,47 @@ for _, v := range sendEntries {
|
||||
if _, ok := ids[v.Id]; ok {
|
||||
return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true)
|
||||
}
|
||||
// Валидация длины BatchEntryId (макс 80 chars)
|
||||
if err := ValidateBatchEntryID(v.Id); err != nil {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
// Валидация DeduplicationID и GroupID (макс 128 chars)
|
||||
if err := ValidateDeduplicationID(v.MessageDeduplicationId); err != nil {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
if err := ValidateGroupID(v.MessageGroupId); err != nil {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
ids[v.Id] = struct{}{}
|
||||
}
|
||||
|
||||
sentEntries := make([]models.SendMessageBatchResultEntry, 0)
|
||||
log.Debugf("Batch sending to Queue: %s (tenant: %s)", queueName, t.ID)
|
||||
|
||||
// Fix #2: проверяем размер каждого сообщения в batch (Critical — batch size bypass)
|
||||
maxMsgSize := models.SyncQueues.Queues[key].MaximumMessageSize
|
||||
if maxMsgSize <= 0 {
|
||||
maxMsgSize = MaxMessageSizeDefault
|
||||
}
|
||||
for _, entry := range sendEntries {
|
||||
if len(entry.MessageBody) > maxMsgSize {
|
||||
return utils.CreateErrorResponseV1("MessageTooBig", true)
|
||||
}
|
||||
// Валидация количества message attributes (макс 10 по AWS)
|
||||
if len(entry.MessageAttributes) > MaxMessageAttributes {
|
||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #11: лимит сообщений в очереди — защита от OOM
|
||||
models.SyncQueues.RLock()
|
||||
currentMsgCount := len(models.SyncQueues.Queues[key].Messages)
|
||||
queueIsFIFO := models.SyncQueues.Queues[key].IsFIFO
|
||||
models.SyncQueues.RUnlock()
|
||||
if currentMsgCount+len(sendEntries) > MaxMessagesForQueue(queueIsFIFO) {
|
||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||
}
|
||||
|
||||
for _, sendEntry := range sendEntries {
|
||||
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
|
||||
if len(sendEntry.MessageAttributes) > 0 {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// app/gosqs/validation.go
|
||||
// AWS SQS-совместимые валидации параметров.
|
||||
// Все лимиты берутся из спецификации AWS SQS — не придумываем свои.
|
||||
// Created: 2026-04-10
|
||||
package gosqs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// ── AWS SQS лимиты ────────────────────────────────────────────────────────────
|
||||
const (
|
||||
// Очередь
|
||||
MaxQueueNameLength = 80
|
||||
MaxMessageSizeDefault = 262144 // 256 KB
|
||||
MaxMessageSizeLimit = 262144
|
||||
MinMessageSizeLimit = 1024 // 1 KB
|
||||
|
||||
// Атрибуты очереди
|
||||
MaxDelaySeconds = 900 // 15 min
|
||||
MaxVisibilityTimeout = 43200 // 12 hours
|
||||
MaxReceiveMessageWaitTimeSeconds = 20 // long polling cap
|
||||
MinMessageRetentionPeriod = 60 // 1 min
|
||||
MaxMessageRetentionPeriod = 1209600 // 14 days
|
||||
|
||||
// Receive
|
||||
MaxNumberOfMessagesLimit = 10
|
||||
MinNumberOfMessagesLimit = 1
|
||||
|
||||
// Message attributes
|
||||
MaxMessageAttributes = 10
|
||||
MaxMessageAttributeSize = 262144 // 256 KB суммарно (тело + атрибуты)
|
||||
|
||||
// Deduplication / GroupID
|
||||
MaxDeduplicationIDLength = 128
|
||||
MaxGroupIDLength = 128
|
||||
|
||||
// Batch
|
||||
MaxBatchEntryIDLength = 80
|
||||
|
||||
// Per-queue message limit (AWS = 120,000 для standard, 20,000 для FIFO)
|
||||
MaxMessagesPerQueue = 120000
|
||||
MaxMessagesPerFIFOQueue = 20000
|
||||
|
||||
// Per-request body size limit
|
||||
MaxRequestBodySize = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
// Global tenant limit
|
||||
MaxTenantsDefault = 1000
|
||||
)
|
||||
|
||||
// queueNameRegex — допустимые символы для имени очереди (AWS SQS)
|
||||
var queueNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+(\.fifo)?$`)
|
||||
|
||||
// ValidateQueueName — проверяет имя очереди по AWS правилам:
|
||||
// макс 80 символов, только [a-zA-Z0-9_-], опционально суффикс .fifo
|
||||
func ValidateQueueName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("QueueName is required")
|
||||
}
|
||||
if len(name) > MaxQueueNameLength {
|
||||
return fmt.Errorf("QueueName exceeds %d characters", MaxQueueNameLength)
|
||||
}
|
||||
if !queueNameRegex.MatchString(name) {
|
||||
return fmt.Errorf("QueueName contains invalid characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClampInt — ограничивает значение в диапазоне [min, max].
|
||||
// Если val < min — возвращает min, если > max — возвращает max.
|
||||
func ClampInt(val, min, max int) int {
|
||||
if val < min {
|
||||
return min
|
||||
}
|
||||
if val > max {
|
||||
return max
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// ValidateDeduplicationID — проверяет длину deduplication ID (макс 128 chars по AWS)
|
||||
func ValidateDeduplicationID(id string) error {
|
||||
if len(id) > MaxDeduplicationIDLength {
|
||||
return fmt.Errorf("MessageDeduplicationId exceeds %d characters", MaxDeduplicationIDLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateGroupID — проверяет длину group ID (макс 128 chars по AWS)
|
||||
func ValidateGroupID(id string) error {
|
||||
if len(id) > MaxGroupIDLength {
|
||||
return fmt.Errorf("MessageGroupId exceeds %d characters", MaxGroupIDLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateBatchEntryID — проверяет длину batch entry ID (макс 80 chars)
|
||||
func ValidateBatchEntryID(id string) error {
|
||||
if len(id) > MaxBatchEntryIDLength {
|
||||
return fmt.Errorf("BatchEntryId exceeds %d characters", MaxBatchEntryIDLength)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateMessageAttributes — проверяет количество атрибутов (макс 10 по AWS)
|
||||
func ValidateMessageAttributes(attrs map[string]interface{}) error {
|
||||
if len(attrs) > MaxMessageAttributes {
|
||||
return fmt.Errorf("message attributes exceed limit of %d", MaxMessageAttributes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaxMessagesForQueue — возвращает лимит сообщений для очереди (FIFO или standard)
|
||||
func MaxMessagesForQueue(isFIFO bool) int {
|
||||
if isFIFO {
|
||||
return MaxMessagesPerFIFOQueue
|
||||
}
|
||||
return MaxMessagesPerQueue
|
||||
}
|
||||
@@ -80,6 +80,11 @@ func SaveQueue(key string, queue *models.Queue) {
|
||||
log.Errorf("persistence: marshal queue %q: %v", key, err)
|
||||
return
|
||||
}
|
||||
// Fix #4.3: Redis size guard — не сохранять если > 50MB (OOM protection)
|
||||
if len(data) > 50*1024*1024 {
|
||||
log.Warnf("persistence: queue %q too large for Redis (%d bytes), skipping", key, len(data))
|
||||
return
|
||||
}
|
||||
asyncWrite(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -26,6 +26,10 @@ import (
|
||||
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Fix #3.5: глобальный лимит размера тела HTTP запроса — 1MB
|
||||
// Защита от OOM при отправке гигантских batch/message requests
|
||||
r.Use(requestBodyLimitMiddleware)
|
||||
|
||||
// /health — публичный, без auth
|
||||
r.HandleFunc("/health", health).Methods("GET")
|
||||
|
||||
@@ -53,6 +57,17 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
// requestBodyLimitMiddleware — ограничивает размер тела HTTP запроса до 1MB.
|
||||
// Защита от OOM при отправке гигантских messages/batch (Fix #3.5).
|
||||
func requestBodyLimitMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1*1024*1024) // 1MB
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) {
|
||||
protocol := resolveProtocol(req)
|
||||
switch protocol {
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"shared-sqs/app/persistence"
|
||||
)
|
||||
|
||||
// MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12)
|
||||
const MaxTenantsGlobal = 1000
|
||||
|
||||
// Tenant — модель тенанта shared-sqs.
|
||||
// AccessKey используется как идентификатор в AWS Authorization header.
|
||||
type Tenant struct {
|
||||
@@ -46,7 +49,14 @@ func NewTenantStore() *TenantStore {
|
||||
}
|
||||
|
||||
// Create — создаёт нового тенанта, генерирует ключи, сохраняет в оба индекса.
|
||||
// Fix #12: глобальный лимит тенантов — защита от OOM при массовом создании.
|
||||
func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
||||
s.mu.RLock()
|
||||
tenantCount := len(s.byID)
|
||||
s.mu.RUnlock()
|
||||
if tenantCount >= MaxTenantsGlobal {
|
||||
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
|
||||
}
|
||||
id, err := generateTenantID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate tenant id: %w", err)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# deployments/k8s/deployment.yaml
|
||||
# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние)
|
||||
# Updated: 2026-04-10 — v0.1.16: JWT auth через nubes API, fix route ordering
|
||||
# Updated: 2026-04-10 — v0.1.17: security fixes (20 vulnerabilities), AWS-compatible limits
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@@ -25,7 +25,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: shared-sqs
|
||||
image: naeel/shared-sqs:v0.1.16
|
||||
image: naeel/shared-sqs:v0.1.17
|
||||
ports:
|
||||
- containerPort: 4100
|
||||
name: http
|
||||
|
||||
Reference in New Issue
Block a user