Files
admin/cms-server-port-leak-fix.md
vitya 5d7a14c7d4 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>
2026-05-19 18:43:03 +03:00

11 KiB
Raw Blame History

title, type, tags, sources, updated
title type tags sources updated
CMS port-leak fix — URL Rewrite serverVariables на host IIS concept
iis
url-rewrite
traefik
x-forwarded
asp-net
gotcha
../sources/iis-host-migration-2026-05-19.md
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://...:8089https://...: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():

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:

@* 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 /xmlMoreThenCms.Web bindings="http/*:80:". SERVER_PORT=80port пустеет (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-recreatenetstat -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):

    $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):

    $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>:

    <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:

    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

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 если будет приоритет.