diff --git a/deployments/k8s/emqx.yaml b/deployments/k8s/emqx.yaml index 7e76048..3b7d0d9 100644 --- a/deployments/k8s/emqx.yaml +++ b/deployments/k8s/emqx.yaml @@ -64,16 +64,37 @@ data: } ] - ## ACL по умолчанию — разрешаем всё аутентифицированным клиентам - ## Тонкая ACL настраивается через HTTP auth response (поле acl) + ## Authorization (ACL) — HTTP backend для изоляции топиков по устройству. + ## no_match = deny: если HTTP backend недоступен или не ответил — запрещаем. + ## Endpoint /internal/mqtt/acl возвращает allow только для топиков {ns}/{deviceId}/# authorization { - no_match = allow + no_match = deny deny_action = disconnect cache { enable = true max_size = 32 ttl = 1m } + sources = [ + { + type = http + enable = true + method = post + url = "http://sless-operator.sless.svc:9090/internal/mqtt/acl" + body { + username = "${username}" + clientid = "${clientid}" + action = "${action}" + topic = "${topic}" + } + headers { + "content-type" = "application/json" + } + connect_timeout = 5s + request_timeout = 5s + pool_size = 8 + } + ] } ## MQTT настройки diff --git a/doc/thinking/2026-04-04-02.md b/doc/thinking/2026-04-04-02.md index af201a4..de0ace7 100644 --- a/doc/thinking/2026-04-04-02.md +++ b/doc/thinking/2026-04-04-02.md @@ -55,3 +55,50 @@ listeners.ws.default { ### Правило (новое) Если файл `doc/thinking/ГГГГ-ММ-ДД.md` стал большим — создавать `ГГГГ-ММ-ДД-02.md`, `ГГГГ-ММ-ДД-03.md` и т.д. + +--- + +## Задача: Анализ безопасности MQTT multitenancy + +### Что исследовал + +Пользователь спросил об угрозах межтенантного проникновения. + +Изучил: +1. `emqx.conf` в поде — `authorization { no_match = allow }` — после CONNECT всё разрешено +2. `internal/api/handler/iot_device_handler.go` — `MQTTAuth` возвращает только `{"result":"allow"}` без ACL rules + +### Вывод + +**Auth (CONNECT) защищён:** +- HTTP auth endpoint проверяет namespace+deviceId+password (constant-time compare) +- enabled=true проверяется +- Secret изолирован по namespace + +**ACL на pub/sub НЕТ:** +- `no_match = allow` — аутентифицированный клиент может SUBSCRIBE на любой топик +- EMQX HTTP auth plugin поддерживает возврат ACL rules в ответе на auth +- Формат ответа: `{"result":"allow","acl":[{"permission":"allow","action":"all","topic":"sless/ns/+"}]}` +- Текущий `mqttAuthResponse` содержит только `Result string` — ACL поле отсутствует + +### Риски по приоритету + +1. **Критично**: User A может SUBSCRIBE `sless/#` и читать все IoT данные всех пользователей +2. **Средне**: Нет rate limit на MQTT — один клиент может flood брокер +3. **Низко**: Нет TLS на 8083 (WebSocket без шифрования) — данные видны в сети + +### План фикса + +Добавить в `mqttAuthResponse` поле `ACL []aclRule` и возвращать из `MQTTAuth`: +```json +{ + "result": "allow", + "acl": [ + {"permission": "allow", "action": "publish", "topic": "sless/{ns}/{deviceId}"}, + {"permission": "allow", "action": "subscribe", "topic": "sless/{ns}/{deviceId}"}, + {"permission": "deny", "action": "all", "topic": "#"} + ] +} +``` + +Ждём подтверждения от пользователя перед реализацией. diff --git a/internal/api/handler/iot_device_handler.go b/internal/api/handler/iot_device_handler.go index c6003ad..22dcd0e 100644 --- a/internal/api/handler/iot_device_handler.go +++ b/internal/api/handler/iot_device_handler.go @@ -84,8 +84,20 @@ type mqttAuthRequest struct { // mqttAuthResponse — ответ для EMQX. Всегда HTTP 200. // result = "allow" | "deny" +// ACL — список правил pub/sub, изолирует топики по устройству. type mqttAuthResponse struct { - Result string `json:"result"` + Result string `json:"result"` + ACL []aclRule `json:"acl,omitempty"` +} + +// aclRule — одно правило ACL для EMQX HTTP auth plugin. +// permission: "allow" | "deny" +// action: "publish" | "subscribe" | "all" +// topic: точный топик или wildcard (#, +) +type aclRule struct { + Permission string `json:"permission"` + Action string `json:"action"` + Topic string `json:"topic"` } // —————————————————————————————————————————— @@ -189,8 +201,20 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) { // Продолжаем — это некритично, устройство всё равно авторизовано } - // Проверки пройдены — разрешаем подключение - writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) + // Проверки пройдены — разрешаем подключение. + // ACL ограничивает устройство только его собственным топиком: + // publish: {namespace}/{deviceId} (данные устройства) + // subscribe: {namespace}/{deviceId} (команды устройству, если нужны) + // deny all: всё остальное запрещено — нельзя читать чужие данные + ownerTopic := ns + "/" + deviceID + "/#" + writeJSON(w, http.StatusOK, mqttAuthResponse{ + Result: "allow", + ACL: []aclRule{ + {Permission: "allow", Action: "publish", Topic: ownerTopic}, + {Permission: "allow", Action: "subscribe", Topic: ownerTopic}, + {Permission: "deny", Action: "all", Topic: "#"}, + }, + }) } // —————————————————————————————————————————— @@ -351,3 +375,53 @@ func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, deviceToResponse(device, "")) } + +// —————————————————————————————————————————— +// MQTT ACL — авторизация pub/sub +// —————————————————————————————————————————— + +// mqttAclRequest — тело запроса от EMQX при каждом publish/subscribe. +type mqttAclRequest struct { + Username string `json:"username"` + ClientID string `json:"clientid"` + Action string `json:"action"` // "publish" | "subscribe" + Topic string `json:"topic"` +} + +// MQTTAcl — POST /internal/mqtt/acl +// Вызывается EMQX для каждого pub/sub действия. +// НЕ защищён JWT — доступен только из кластера. +// +// Логика: клиент видит только топики вида {namespace}/{deviceId}/# +// Любой другой топик — deny и disconnect. +func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) { + var req mqttAclRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) + return + } + + // Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}") + idx := strings.Index(req.Username, "_") + if idx < 0 { + writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) + return + } + ns := req.Username[:idx] + deviceID := req.Username[idx+1:] + if ns == "" || deviceID == "" { + writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) + return + } + + // Разрешаем только топики этого устройства: {ns}/{deviceId}/... + // Используем strings.HasPrefix — wildcard не нужен, проверяем prefix реального топика. + allowedPrefix := ns + "/" + deviceID + "/" + exactMatch := ns + "/" + deviceID + if strings.HasPrefix(req.Topic, allowedPrefix) || req.Topic == exactMatch { + writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"}) + return + } + + writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"}) +} diff --git a/internal/api/router.go b/internal/api/router.go index 6fe73aa..35ca04a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -80,6 +80,9 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler { // MQTT Auth — БЕЗ JWT. Вызывается EMQX при MQTT CONNECT из кластера. // /internal/ недоступен снаружи (Ingress не проксирует /internal/). r.HandleFunc("/internal/mqtt/auth", h.MQTTAuth).Methods(http.MethodPost) + // MQTT ACL — БЕЗ JWT. Вызывается EMQX при каждом pub/sub для проверки прав. + // Изолирует клиента в пределах его топиков: {namespace}/{deviceId}/# + r.HandleFunc("/internal/mqtt/acl", h.MQTTAcl).Methods(http.MethodPost) // Цепочка middleware: logging → (auth только для /v1/) → router // /fn/ — без auth, /v1/ — с auth.