docs(.wiki,.tasks): close cms-port-leak-fix — URL Rewrite serverVariables на host IIS

Port :8089/:4443 утечка в admin URLs закрыта. URL Rewrite 2.1 + apphost
allowedServerVariables (HTTPS/SERVER_PORT/SERVER_PORT_SECURE) + Web.config rule
на X-Forwarded-Proto=https → set SERVER_PORT=443/SERVER_PORT_SECURE=1/HTTPS=on
ДО того как ASP.NET читает их в Url.SiteRoot(). Лечит все 11 cms (общий site
snolla). Path B (traefik http entrypoint :80→:8090) abandoned — Docker Desktop
WSL2 NAT quirk на host.docker.internal:80 возвращает 17-byte 301 plain text
независимо от traefik internals. Quirk не reproducible на Linux Docker (synology).

Side regression: pre-existing customErrors mode="off" lowercase в Web.config
пробудился после ASP.NET full-reload (мой rewrite-block edit) — fixed (Off).

Outside scope, open: emspb.snolla.com /admin/assets/<guid>/getList → 500 NullRef
в AssetsJsonViewModelBuilder.cs:22 (model null от GetFolderByPath). User
подтвердил «только этот site». Зафиксировано в snapshot open issue #8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 18:43:03 +03:00
parent 3d5d61d5a5
commit 5d7a14c7d4
3 changed files with 181 additions and 20 deletions

174
cms-server-port-leak-fix.md Normal file
View File

@@ -0,0 +1,174 @@
---
title: CMS port-leak fix — URL Rewrite serverVariables на host IIS
type: concept
tags: [iis, url-rewrite, traefik, x-forwarded, asp-net, gotcha]
sources: [../sources/iis-host-migration-2026-05-19.md]
updated: 2026-05-19
---
# CMS port-leak fix
Решение для open issue #1 из [[recovery-architecture-snapshot]] (раньше «X-Forwarded headers не настроены, :4443 leak») и админ-utечки `:8089` обнаруженной 2026-05-19 вечером после attempt 2 host-IIS миграции. Документирует **root cause**, **почему VM работала**, **почему обходной path-B на Windows Docker Desktop невозможен**, и **что в итоге применено**.
Связано: [[traefik-on-windows-docker-desktop]] Pitfall 5, [[iis-host-migration-2026-05-19]] Phase 10, [[docker-host-loopback-detect]].
## Симптом
Admin URLs формата (после attempt 2 миграции на host-IIS:8089):
- `https://emspb.snolla.com:8089/admin/assets/<guid>/getList?path=`
- `https://emspb.snolla.com:8089/admin/themes/getImageSizes/`
- `https://emspb.snolla.com:8089/admin/templates/editors.tmpl.html?v=2.006`
Browser HSTS upgrade'ит `http://...:8089``https://...:8089` → TCP open, TLS handshake fails (8089 = plain HTTP) → admin SPA ломается.
Аналогично, ранее (issue #1) — CMS делал HTTP→HTTPS redirect с `:4443` (traefik external port).
## Root cause
`MoreThenCms.Admin\Mis\Web\Mvc\UrlHelpers\UrlHelpers.cs:14-37` `Url.SiteRoot()`:
```csharp
var port = context.Request.ServerVariables["SERVER_PORT"];
if (usePort) {
if (port == null || port == "80" || port == "443") port = "";
else port = ":" + port;
}
var protocol = context.Request.ServerVariables["SERVER_PORT_SECURE"];
if (protocol == null || protocol == "0") protocol = "http://"; else protocol = "https://";
var sOut = protocol + context.Request.ServerVariables["SERVER_NAME"] + port + appPath;
```
Читает **socket-level** server variables. На IIS site `snolla` binding `*:8089` HTTP → `SERVER_PORT=8089`, `SERVER_PORT_SECURE=0` → формирует `http://emspb.snolla.com:8089` → рендерится в Razor:
```cshtml
@* C:\sites\snolla\Views\Shared\_Layout.cshtml:229 *@
mis.siteRoot = '@Url.SiteRoot().Replace("http:", "https:")' + '/admin';
@* и _LogInLayout.cshtml:156 *@
mis.siteRoot = '@Url.SiteRoot()';
```
Замена `http:→https:` в Layout была *прошлым* частичным patch'ем — не убирает порт. Десятки .cshtml в admin также используют `Url.SiteRoot()` для inline `<style background: url(... + "/images/loading2.gif")>` — все utекают `:8089`.
## Почему VM работала (без всяких rewrite)
SSH probe VM (parallel running snolla-recovery): `appcmd list site /xml``MoreThenCms.Web bindings="http/*:80:"`. **`SERVER_PORT=80`** → `port` пустеет (whitelist `{80,443}`), `mis.siteRoot = 'http://emspb.snolla.com'` (no port, http scheme). Browser HSTS upgrade'ит до https и попадает обратно через router-NAT-traefik-chain в backend. Работает.
VM `applicationHost.config` проверен — **никаких** rewrite rules, никакого URL Rewrite/ARR модуля. Production magic = просто IIS бинд на `:80`.
## Почему path B (host IIS :80 + traefik backend :80) НЕ работает на Windows Docker Desktop
Pre-flight 2026-05-19 вечер:
```
docker exec traefik wget -qS http://host.docker.internal:80/admin/account/login
→ HTTP/1.1 301 Moved Permanently
Content-Length: 17
Content-Type: text/plain
Location: https://emspb.snolla.com/admin/account/login
(НЕТ Server: header — не IIS, не traefik)
```
`Get-NetTCPConnection -LocalPort 80 -State Listen` → только `PID=4 NAME=System` (http.sys/IIS). Никакого другого listener'а. Direct `Invoke-WebRequest http://127.0.0.1/admin/...` с правильным Host header возвращает 200 OK с `Server: Microsoft-IIS/10.0`.
Но через **`host.docker.internal:80`**, **`gateway.docker.internal:80`** (192.168.65.1), **`192.168.1.143:80`** (LAN IP) — все три возвращают тот же 17-byte 301 plain text. То есть **Docker Desktop's WSL2 NAT proxy на Windows перехватывает container-→host:80 traffic и подменяет ответ HTTP→HTTPS redirect'ом**.
Подтверждение что **не** traefik: после смены traefik http entrypoint `:80 → :8090` (`docker-compose.yml ports: 8000:8090`, `traefik.yml http.address: ":8090"`) и `docker compose up -d --force-recreate``netstat -tlnp` в контейнере показывает `:8090 LISTEN`, **нет** `:80`. И всё равно `host.docker.internal:80` возвращает тот же 17-byte 301.
**Этого quirk'а нет на Linux Docker** (synology) — там `host.docker.internal` либо не существует, либо ведёт себя стандартно. Поэтому VM-стек работал, host-Windows-Docker — нет.
## Применённое решение (path C)
URL Rewrite 2.1 + `<serverVariables>` rule на host IIS — переписывает `SERVER_PORT/SERVER_PORT_SECURE/HTTPS` ДО того как ASP.NET их читает.
### Шаги
1. **MSI install URL Rewrite 2.1** (elevated):
```powershell
$msi = "$env:TEMP\rewrite_amd64_en-US.msi"
Invoke-WebRequest 'https://download.microsoft.com/download/1/2/8/128E2E22-C1B9-44A4-BE2A-5859ED1D4592/rewrite_amd64_en-US.msi' -OutFile $msi
Start-Process msiexec -ArgumentList "/i `"$msi`" /quiet /norestart" -Verb RunAs -Wait
```
Verify: `Get-Item C:\Windows\System32\inetsrv\rewrite.dll` (FileVersion 7.1.1993.2351).
2. **applicationHost.config — `<allowedServerVariables>`** (elevated, через appcmd):
```powershell
$appcmd = 'C:\Windows\System32\inetsrv\appcmd.exe'
foreach ($n in 'HTTPS', 'SERVER_PORT', 'SERVER_PORT_SECURE') {
& $appcmd set config -section:system.webServer/rewrite/allowedServerVariables "/+[name='$n']" /commit:apphost
}
```
**Built-in IIS server variables** (HTTPS/SERVER_PORT/*) MUST be в apphost-allowedServerVariables, **не** site Web.config. Иначе HTTP 500 "URL Rewrite Module Error" без body.
3. **Site `C:\sites\snolla\Web.config`** (UTF-8 BOM!) — добавить в `<system.webServer>`:
```xml
<rewrite>
<rules>
<rule name="ForwardedProto-HTTPS" stopProcessing="false">
<match url=".*" />
<conditions>
<add input="{HTTP_X_FORWARDED_PROTO}" pattern="^https$" />
</conditions>
<serverVariables>
<set name="HTTPS" value="on" />
<set name="SERVER_PORT" value="443" />
<set name="SERVER_PORT_SECURE" value="1" />
</serverVariables>
<action type="None" />
</rule>
</rules>
</rewrite>
```
`<action type="None"/>` — rule не редиректит/переписывает URL, только сидит как side-effect ставящая server vars. `stopProcessing="false"` чтобы другие rules могли продолжить (на случай если добавим).
4. **Recycle** — IIS auto-recycle при изменении Web.config (~3 сек).
5. **Verify**:
```bash
docker exec traefik sh -c "wget -qS --no-check-certificate -O- \
--header='Host: emspb.snolla.com' \
https://127.0.0.1:443/admin/account/login 2>&1 | grep siteRoot"
# Expected: mis.siteRoot = 'https://emspb.snolla.com'; (БЕЗ :8089)
```
Traefik 2.x по-default шлёт `X-Forwarded-Proto: https` для requests через https entrypoint — наш `HTTP_X_FORWARDED_PROTO` condition fires автоматически.
### Atomic revert
```powershell
Copy-Item C:\sites\snolla\Web.config.bak-pre-portleak-2026-05-19 C:\sites\snolla\Web.config -Force
# ~3 сек на app pool reload
# apphost можно оставить (allowedServerVariables inert без rule)
# MSI URL Rewrite можно оставить (модуль без rule = no behavior)
```
## Gotchas
### `customErrors mode="off"` lowercase — fatal regression
`C:\sites\snolla\Web.config:43` имел `<customErrors mode="off" />` (lowercase). ASP.NET до моего edit'а **lazily** валидировал это — работало. После моего Web.config edit'а (добавил `<rewrite>` block) → ASP.NET full-reload → enum parse fail:
```
Значение свойства 'mode' не может быть проанализировано.
Ошибка: Значение перечисления должно быть одним из следующих: RemoteOnly, On, Off.
```
YSOD config error блокирует **весь** site. Fix: `mode="Off"` (capital O). Урок: при любом Web.config edit'е сначала grep на enum-style attributes на корректный casing.
### `[xml]` save в PowerShell flattens applicationHost.config
`$cfg.OuterXml` сбрасывает все пробелы/переносы → файл 937 строк превращается в 1 строку. IIS читает (whitespace для XML parser не важен), но файл нечитаем для людей. Backup и `appcmd set config /+...` — единственно правильный способ редактировать apphost programmatically.
### Site-level `<allowedServerVariables>` для built-in vars
Site `Web.config` MOJET содержать `<rewrite><allowedServerVariables>` ТОЛЬКО для custom variables (HTTP_X_*, etc). Для built-in IIS vars (HTTPS, SERVER_PORT, SERVER_PORT_SECURE) — apphost OR HTTP 500. Не дублировать в site-level — конфликт.
## Сделано на host'е (2026-05-19 вечер)
- `C:\Windows\System32\inetsrv\config\applicationHost.config` — `<rewrite>/<allowedServerVariables>` += HTTPS, SERVER_PORT, SERVER_PORT_SECURE. Backup `.bak-pre-portleak-2026-05-19`.
- `C:\sites\snolla\Web.config` — `<rewrite>/<rules>` += rule "ForwardedProto-HTTPS"; `customErrors mode="off"` → `"Off"`. Backup `.bak-pre-portleak-2026-05-19`.
- URL Rewrite 2.1 MSI installed.
- Затрагивает **все 11 cms hosts** (общий site `snolla`): emspb, labtools, labtoolspro, pilorama98, tandemmebel, kupimknigi, maljarka, sestech, isc-artmaterials, rimiz, plus snolla.com sub-domains.
## Известное ограничение (НЕ закрыто этим fix'ом)
`/admin/assets/<guid>/getList?path=` на `emspb.snolla.com` admin → 500 NullReferenceException в `AssetsJsonViewModelBuilder.cs:22` (`model.ParentPath` на null). User подтвердил «только этот site». `_assetsFoldersService.GetFolderByPath(ctx, ownerId, '')` возвращает null. Скорее всего связано с пустыми settings `<fileStorageClients><add name="assets">` (basePath/siteUrl/sitePath = ""). **Отдельный bug**, не в scope этого fix'а — отдельная task если будет приоритет.

View File

@@ -147,9 +147,10 @@ ACL: `IIS AppPool\<site>:(OI)(CI)M` рекурсивно на каждом site
## Известные открытые баги ## Известные открытые баги
1. **X-Forwarded headers** не передаются от traefik в IIS → CMS делает redirect с `:4443` в URL. См. [[traefik-on-windows-docker-desktop]] Pitfall 5. 1. ~~**X-Forwarded headers** не передаются от traefik в IIS → CMS делает redirect с `:4443` в URL.~~**RESOLVED 2026-05-19 вечер** через URL Rewrite 2.1 + `<serverVariables>` rule на host IIS. Также закрыл утечку `:8089` в admin SPA после attempt 2 миграции. См. [[cms-server-port-leak-fix]].
2. **rimiz.ru → 404**. CMS-side, не инфра. 2. **rimiz.ru → 404**. CMS-side, не инфра.
3. **ics-artmaterials.com → 404**. Аналогично — CMS-side (`www.ics-artmaterials.com → 301 → ics-artmaterials.com → 404`). 3. **ics-artmaterials.com → 404**. Аналогично — CMS-side (`www.ics-artmaterials.com → 301 → ics-artmaterials.com → 404`).
8. **emspb.snolla.com `/admin/assets/<guid>/getList` → 500 NullReferenceException** в `AssetsJsonViewModelBuilder.cs:22` (`model` null). User подтвердил «только этот site». Не в scope port-leak fix; CMS-side bug в storage/Site mapping. Открыт.
4. **acme.json HTTP-01 renewal** будет фейлить для доменов с DNS не на нашем IP → переезд на DNS-01 через REGRU до истечения сертификатов (~3 месяца). 4. **acme.json HTTP-01 renewal** будет фейлить для доменов с DNS не на нашем IP → переезд на DNS-01 через REGRU до истечения сертификатов (~3 месяца).
5. **C:\inetpub\logs\** растёт — нужна ротация. 5. **C:\inetpub\logs\** растёт — нужна ротация.
6. **docker.sock provider в traefik** не работает — но не блокирует (всё через file-provider). См. [[traefik-on-windows-docker-desktop]] Pitfall 3. 6. **docker.sock provider в traefik** не работает — но не блокирует (всё через file-provider). См. [[traefik-on-windows-docker-desktop]] Pitfall 3.

View File

@@ -122,29 +122,15 @@ servers:
Docker Desktop резолвит `host.docker.internal` в IP хоста (обычно 172.x.x.1 из docker bridge perspective). Дальше Windows-host обрабатывает 18080 → VBox NAT → VM:80 → IIS → CMS. Docker Desktop резолвит `host.docker.internal` в IP хоста (обычно 172.x.x.1 из docker bridge perspective). Дальше Windows-host обрабатывает 18080 → VBox NAT → VM:80 → IIS → CMS.
## Pitfall 5: X-Forwarded headers НЕ настроены ## Pitfall 5: X-Forwarded не используется ASP.NET — port utечка в admin URLs (RESOLVED 2026-05-19)
Traefik отдаёт backend (IIS) запрос с заголовком `Host: snolla.com`, но **не** добавляет `X-Forwarded-Proto: https` / `X-Forwarded-Host`. Backend (IIS) считает, что получил HTTP-запрос, делает 301 redirect на канонический URL `http://www.<domain>:4443/`. **Симптом:** CMS-admin генерирует URLs с internal IIS portом (`:8089` после attempt 2; ранее `:4443` от traefik) → browser HSTS auto-upgrade ломает TLS на нестандартном портe → admin SPA broken.
Симптом для клиента: видит URL с портом 4443 в адресной строке после редиректа. **Root cause:** Traefik 2.x **шлёт** `X-Forwarded-Proto: https` / `X-Forwarded-Host` / `X-Forwarded-Port` по-default (когда entrypoint https). НО CMS-код (`MoreThenCms.Admin\Mis\Web\Mvc\UrlHelpers\UrlHelpers.cs`) читает **socket-level** `SERVER_PORT` / `SERVER_PORT_SECURE`, **игнорируя** X-Forwarded-* headers. На VM работало случайно потому что IIS binding `:80``SERVER_PORT=80` стрипалось whitelist'ом `{80,443}` в коде.
**Fix (TODO, не делали в emergency):** **Fix:** URL Rewrite 2.1 + `<serverVariables>` rule на host IIS — переписывает `HTTPS`/`SERVER_PORT`/`SERVER_PORT_SECURE` на основе `X-Forwarded-Proto`. Детально в [[cms-server-port-leak-fix]].
```yaml Path B (поменять traefik http entrypoint :80 inside container чтобы IIS-binding :80 не получал loop от Docker NAT) — **не работает** на Windows Docker Desktop из-за WSL2 NAT quirk, см. там же.
# data/custom/forwarded-headers-middleware.yml
http:
middlewares:
secure-headers:
headers:
customRequestHeaders:
X-Forwarded-Proto: https
customResponseHeaders:
X-Forwarded-Proto: ""
```
И применить middleware к каждому router (или к глобальному entrypoint-level через `--entrypoints.https.forwardedheaders.insecure=true` в command).
Plus на стороне IIS: установить ARR module + URL Rewrite, доверять `X-Forwarded-*` от 127.0.0.1 (host).
## DNS-01 challenge через REGRU ## DNS-01 challenge через REGRU