diff --git a/docs/specs/043-private-support-report.md b/docs/specs/043-private-support-report.md index 4164e05c..e8d85a7f 100644 --- a/docs/specs/043-private-support-report.md +++ b/docs/specs/043-private-support-report.md @@ -443,6 +443,13 @@ if they are unavailable, #43 receives `blocked` without weakening the contract. - attachment parsed and checked for format/version and top-level allowlist; - message/contact rendered as escaped plain text only; - 5 attempts/hour and 20/day per source address plus a global circuit breaker; +- **the source address is the one supplied by the trusted proxy, never one the + client can choose.** The relay reads the *last* element of `X-Forwarded-For`, + because the first element is whatever the caller sent, and the reverse proxy in + front of it is configured to overwrite the header outright rather than append + to it. Both halves are required: without them a caller picks its own rate-limit + bucket by changing one line of the request, and every other limit in this + section becomes decorative; - source IP is used only through a daily-keyed rate-limit hash with ≤24 h TTL; raw address is not written to app logs/storage/mail; - idempotency key retained 24 h and returns the original report id; @@ -529,6 +536,7 @@ parse them; user message/contact remain verbatim plain text. | AC10 | Expired/replaced/discarded tokens fail; config changes after preview do not change cached bytes. | Fake-clock backend tests. | | AC11 | Submit is HTTPS/fixed-host/no-redirect, bounded and idempotent; logs contain no message/contact/body. | Stub aiohttp server + caplog + SSRF/redirect tests. | | AC12 | Relay enforces schema, size, hash, idempotency and rate limits; HTML remains inert plain text. | Receiver unit/integration suite with a recording fake provider. | +| AC12a | A caller cannot select its own rate-limit bucket: requests carrying different forged `X-Forwarded-For` values land in one source key, and the proxy configuration overwrites the header. | Receiver test plus the deployed proxy fragment under `scripts/support-relay/deploy/`. | | AC13 | Success shows stable report id; timeout/error preserves form and exposes retry/manual recovery without claiming success. | Browser smoke across success/429/timeout/unknown command. | | AC14 | Phone/tablet dialog, keyboard/focus and 44 px target satisfy View touch/accessibility contract. | Reviewed desktop + phone + tablet goldens and touch smoke. | | AC15 | No existing backup/diagnostics/preflight behavior or payload changes. | Existing targeted frontend/backend suites unchanged. | @@ -581,6 +589,8 @@ Mutation gate must prove at least: - authorization guard removed → backend test red; - redirect enabled/arbitrary URL accepted → SSRF test red; - relay skips hash/rate/idempotency check → receiver tests red; +- relay trusts the client-supplied end of `X-Forwarded-For` (first element + instead of last) → `test_client_cannot_pick_its_own_rate_bucket` red; - success shown on timeout → browser smoke red. ## 15. Performance and reliability budgets diff --git a/scripts/support-relay/README.md b/scripts/support-relay/README.md index 27a85bd8..0f9e412f 100644 --- a/scripts/support-relay/README.md +++ b/scripts/support-relay/README.md @@ -151,6 +151,33 @@ sudo systemctl restart hp-support-relay@prod Ротация вебхука — это ещё и правка `webhook_id` в автоматизации Home Assistant «House Plan: приёмщик обратной связи → личка»: адрес и есть ключ. +**Минимальная конфигурация той автоматизации** — на случай, если её придётся +пересоздать (она живёт в чужом Home Assistant, не в этом репозитории): + +```yaml +alias: "House Plan: приёмщик обратной связи → личка" +mode: queued +max: 10 +triggers: + - trigger: webhook + webhook_id: "<тот же идентификатор, что в webhook.url>" + allowed_methods: [POST] + local_only: false # relay вызывает её снаружи +conditions: + - condition: template + value_template: "{{ trigger.json.source == 'houseplan-support-relay' }}" +actions: + - action: telegram_bot.send_message + data: + entity_id: notify.<получатель> + parse_mode: plain_text # текст пишет посторонний человек + disable_web_page_preview: true + message: "{{ trigger.json.text }}" +``` + +`parse_mode: plain_text` здесь не косметика: без него сообщение пользователя +разбирается как разметка и может подделать вид всего уведомления. + **Прочитать обращение** ```bash