security(iot): MQTT ACL isolation via EMQX HTTP authorization
Each IoT device can only pub/sub to its own topics: {namespace}/{deviceId}/#
Any attempt to access foreign topics → EMQX denies and disconnects.
Changes:
- internal/api/handler: add MQTTAcl handler (POST /internal/mqtt/acl)
- internal/api/router: register /internal/mqtt/acl route
- deployments/k8s/emqx.yaml: add HTTP authorization backend, no_match=deny
- Operator v0.1.52 deployed
Tested: own topic ALLOWED, foreign topic → authorization_permission_denied + disconnect
This commit is contained in:
@@ -64,16 +64,37 @@ data:
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
## ACL по умолчанию — разрешаем всё аутентифицированным клиентам
|
## Authorization (ACL) — HTTP backend для изоляции топиков по устройству.
|
||||||
## Тонкая ACL настраивается через HTTP auth response (поле acl)
|
## no_match = deny: если HTTP backend недоступен или не ответил — запрещаем.
|
||||||
|
## Endpoint /internal/mqtt/acl возвращает allow только для топиков {ns}/{deviceId}/#
|
||||||
authorization {
|
authorization {
|
||||||
no_match = allow
|
no_match = deny
|
||||||
deny_action = disconnect
|
deny_action = disconnect
|
||||||
cache {
|
cache {
|
||||||
enable = true
|
enable = true
|
||||||
max_size = 32
|
max_size = 32
|
||||||
ttl = 1m
|
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 настройки
|
## MQTT настройки
|
||||||
|
|||||||
@@ -55,3 +55,50 @@ listeners.ws.default {
|
|||||||
### Правило (новое)
|
### Правило (новое)
|
||||||
|
|
||||||
Если файл `doc/thinking/ГГГГ-ММ-ДД.md` стал большим — создавать `ГГГГ-ММ-ДД-02.md`, `ГГГГ-ММ-ДД-03.md` и т.д.
|
Если файл `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": "#"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ждём подтверждения от пользователя перед реализацией.
|
||||||
|
|||||||
@@ -84,8 +84,20 @@ type mqttAuthRequest struct {
|
|||||||
|
|
||||||
// mqttAuthResponse — ответ для EMQX. Всегда HTTP 200.
|
// mqttAuthResponse — ответ для EMQX. Всегда HTTP 200.
|
||||||
// result = "allow" | "deny"
|
// result = "allow" | "deny"
|
||||||
|
// ACL — список правил pub/sub, изолирует топики по устройству.
|
||||||
type mqttAuthResponse struct {
|
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, ""))
|
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"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
|
|||||||
// MQTT Auth — БЕЗ JWT. Вызывается EMQX при MQTT CONNECT из кластера.
|
// MQTT Auth — БЕЗ JWT. Вызывается EMQX при MQTT CONNECT из кластера.
|
||||||
// /internal/ недоступен снаружи (Ingress не проксирует /internal/).
|
// /internal/ недоступен снаружи (Ingress не проксирует /internal/).
|
||||||
r.HandleFunc("/internal/mqtt/auth", h.MQTTAuth).Methods(http.MethodPost)
|
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
|
// Цепочка middleware: logging → (auth только для /v1/) → router
|
||||||
// /fn/ — без auth, /v1/ — с auth.
|
// /fn/ — без auth, /v1/ — с auth.
|
||||||
|
|||||||
Reference in New Issue
Block a user