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:
174
cms-server-port-leak-fix.md
Normal file
174
cms-server-port-leak-fix.md
Normal 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 если будет приоритет.
|
||||
@@ -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, не инфра.
|
||||
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 месяца).
|
||||
5. **C:\inetpub\logs\** растёт — нужна ротация.
|
||||
6. **docker.sock provider в traefik** не работает — но не блокирует (всё через file-provider). См. [[traefik-on-windows-docker-desktop]] Pitfall 3.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
# 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).
|
||||
Path B (поменять traefik http entrypoint :80 inside container чтобы IIS-binding :80 не получал loop от Docker NAT) — **не работает** на Windows Docker Desktop из-за WSL2 NAT quirk, см. там же.
|
||||
|
||||
## DNS-01 challenge через REGRU
|
||||
|
||||
|
||||
Reference in New Issue
Block a user