diff --git a/SPEC.md b/SPEC.md index 7e1daf1..bebcf8d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -25,27 +25,36 @@ No ORM, controller auto-discovery, automatic reflection, hot reload, plugin syst Prefer a bootstrap with `Option Explicit`, no business logic, no includes, no embedded HTML, and only request-host wiring. Define and verify a precise ownership rule for HTTP status, headers, and writing: do not write a partially successful response before dispatch can fail. Test whether COM calls can accept the proposed ASP built-in objects; if passing an object fails, use a tested host adapter or pass only primitive request data. Do not silently assume an ASP object is serializable or universally callable from WSC. ## 5. Target project structure + +Revised 2026-09-19 (explicit user direction, superseding the original v0.1 tree): IIS's site physical path is the `public/` folder **only**. Every other project directory is a sibling of `public/`, entirely outside the served tree — not reachable by IIS regardless of `web.config`, which is a stronger guarantee than request-filtering rules over a shared directory. `Default.asp` reaches sibling directories (e.g. `logs/`) via a parent-relative `Server.MapPath`, which requires `enableParentPaths=true` for the site. See `docs/DECISIONS.md` for the full rationale and `docs/ARCHITECTURE.md` for how it's wired up. + ``` WSC-MVC/ - Default.asp - web.config + public/ + Default.asp + web.config Framework/ Application.wsc Router.wsc # phase 3 - RequestContext.wsc # phase 2; only after host interop proven + RequestContext.wsc ResponseResult.wsc # phase 2; optional if simpler contract works ViewRenderer.wsc # phase 4 Controllers/ HomeController.wsc + SelfTestController.wsc Views/ Home.html # phase 4 Layout.html # phase 4 + logs/ + app.log tests/ Test-Components.vbs Test-Http.ps1 + run-self-test.sh tools/ Register-Components.ps1 Unregister-Components.ps1 + Setup-Site.ps1 docs/ ARCHITECTURE.md DECISIONS.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a35e66e..64842b2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,11 +55,12 @@ CLSIDs are fixed at creation and must never be recycled for a different componen ## IIS site (test host: win2025test, 100.127.62.31) -- Site: `WscMvc`, binding `*:8090`, physical path `C:\Projects\wsc-mvc`. +- Site: `WscMvc`, binding `*:8090`, physical path `C:\Projects\wsc-mvc\public` — **not** the project root. `public/` contains only `Default.asp` and `web.config`; every other directory (`Framework/`, `Controllers/`, `tests/`, `tools/`, `docs/`, `logs/`) is a sibling of `public/`, outside IIS's site physical path entirely. Changed 2026-09-19 at Daniel's explicit direction (see `docs/DECISIONS.md`); previously the site root was the whole project directory with those folders hidden via `hiddenSegments`. - App pool: `WscMvc`, 64-bit, no managed code. Anonymous authentication identity: `IUSR`. -- `web.config` hides `Framework/`, `Controllers/`, `tests/`, `tools/`, `docs/`, `logs/` (path-segment based, anywhere in the tree) and denies `.wsc/.vbs/.ps1/.md` by extension. -- `logs/` has an explicit, scoped `icacls ... /grant "IIS_IUSRS:(OI)(CI)M"` so classic ASP (impersonating `IUSR` for anonymous requests) can write `app.log`. No other project directory grants `IUSR`/`IIS_IUSRS` write access — verified with a recursive `icacls /T` scan, see `docs/DECISIONS.md`. -- Default document is `Default.asp`; `/hello` is served via a rewrite rule, not the default document. +- `public/web.config` keeps only a `.wsc/.vbs/.ps1/.md` extension denylist as defense-in-depth (in case a stray file ever lands directly in `public/`); no `hiddenSegments` are needed since the directories they used to hide no longer exist under the served root at all. +- `enableParentPaths=true` is set for this site (scoped via `appcmd ... /commit:apphost`, a `` block in `applicationHost.config` — not a machine-wide unlock of the locked `system.webServer/asp` section) so `Default.asp` can `Server.MapPath("../logs")` to reach `logs/`. This setting affects only server-side script `MapPath`/`#include` resolution, not client-supplied URL paths — IIS's own URL normalization independently rejects `..`-traversal in an incoming request regardless of this setting; verified with a direct request attempt, see `docs/DECISIONS.md`. +- `logs/` (`C:\Projects\wsc-mvc\logs`, unchanged physical location from before this restructure) has an explicit, scoped `icacls ... /grant "IIS_IUSRS:(OI)(CI)M"` so classic ASP (impersonating `IUSR` for anonymous requests) can write `app.log`. No other project directory grants `IUSR`/`IIS_IUSRS` write access — verified with a recursive `icacls /T` scan, see `docs/DECISIONS.md`. +- Default document is `Default.asp`; `/hello` and `/self-test` are served via rewrite rules, not the default document. ## Deferred to later milestones (do not implement early) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3e3ae93..0fdc8a0 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -38,9 +38,11 @@ With the out-of-the-box `system.webServer/httpErrors` setting (`errorMode="Detai Diagnostic note: `system.webServer/asp` is locked (`overrideModeDefault="Deny"`) at the machine level by default, so it cannot be overridden from a site's own `web.config`. Toggling `scriptErrorSentToBrowser` for diagnosis requires `appcmd unlock config /section:system.webServer/asp` first; remember to `appcmd lock config /section:system.webServer/asp` again afterward and to manually strip any `` element that `Set-WebConfigurationProperty` writes into the site's `web.config` before re-locking, or the site's config becomes invalid (locked section referenced from a location that has an explicit override) and every request 500s until the stray element is removed. This happened once during M1 testing on this host and was fixed by editing `web.config` directly; the repository's `web.config` was never affected (this only happened to the deployed copy on the VM). -## Static/source protection approach +## Static/source protection approach (M1; superseded, see the `public/`-only webroot entry below) -Used IIS `requestFiltering/hiddenSegments` (blocks any URL path containing `Framework`, `Controllers`, `tests`, `tools`, `docs` as a path segment, anywhere in the tree) plus `requestFiltering/fileExtensions` denylist for `.wsc`, `.vbs`, `.ps1`, `.md`. This keeps `Default.asp` at the project root (matching the SPEC §5 target tree) while denying direct access to source/config/test files, rather than moving `Default.asp` into a separate `public/` webroot. Verified with `tests/Test-Http.ps1` (direct `.wsc` request must return 404). +M1's original approach: IIS `requestFiltering/hiddenSegments` (blocks any URL path containing `Framework`, `Controllers`, `tests`, `tools`, `docs` as a path segment, anywhere in the tree) plus `requestFiltering/fileExtensions` denylist for `.wsc`, `.vbs`, `.ps1`, `.md`, with `Default.asp` at the project root (matching the SPEC §5 target tree at the time) rather than a separate `public/` webroot. Verified with `tests/Test-Http.ps1` (direct `.wsc` request must return 404). + +**Superseded 2026-09-19**: replaced with physical separation (`public/`-only IIS site root) at Daniel's explicit direction — see that entry below for the current approach and rationale. The `fileExtensions` denylist is kept as defense-in-depth; `hiddenSegments` was removed since it no longer protects anything that exists under the served root. ## Site/port allocation @@ -71,3 +73,15 @@ Daniel asked for the test harness to be "frontend agnostic" — runnable via a p Design choice: HTTP status is always `200` when the self-test mechanism itself executes; pass/fail lives in the JSON body (`"ok"` and per-check `"pass"`). A `5xx` is reserved for the diagnostics mechanism itself being broken (e.g. `SelfTestController` fails to instantiate), which still flows through `Application.Run`'s existing central error handling unchanged. This mirrors conventional health-check endpoint design and keeps a clean separation between "the test infrastructure is broken" and "a test found a real problem." `SelfTestController` is the first component whose failure details intentionally include raw `Err.Description` — everywhere else in this codebase that would violate SPEC §10 ("avoid... raw exception text to clients"), but a diagnostics-only endpoint's entire job is reporting what broke. Flagged in `docs/ARCHITECTURE.md` for reconsideration (gate or remove) before any production deployment, during the M6 hardening pass — not a concern for the current dev/test milestones. + +## Restructure: `public/`-only webroot, physical separation over request-filtering (2026-09-19) + +Daniel asked for `Default.asp` to be served out of a `public/` folder with `public/` as the *only* folder IIS serves, plus `enableParentPaths` so the app can still reach sibling directories. This changes SPEC §5's originally fixed target tree (`Default.asp` at the project root) — done with Daniel's explicit direction, per AGENTS.md's precedence rule that user instructions outrank a SPEC "fixed decision," and SPEC.md §5 has been updated in place to document the new tree as current, not left silently stale. + +**What changed**: `Default.asp` and `web.config` moved into `public/`; `Framework/`, `Controllers/`, `tests/`, `tools/`, `docs/`, `logs/` are now siblings of `public/`, entirely outside it. The IIS site's `physicalPath` was changed from the project root to `C:\Projects\wsc-mvc\public`. This is strictly stronger than the previous `hiddenSegments`-based approach: those directories aren't merely denied by a request-filtering rule now, they simply don't exist anywhere under the served tree, so there's no config file whose misconfiguration could ever expose them. + +**`enableParentPaths`**: `Default.asp` needs `Server.MapPath("../logs")` since `logs/` is now outside `public/`. `system.webServer/asp` is locked (`overrideModeDefault="Deny"`) at the machine level (already discovered during M1 diagnostics), so it can't be set from `public/web.config` directly. Used `appcmd set config WscMvc -section:system.webServer/asp /enableParentPaths:True /commit:apphost` instead of the machine-wide unlock/relock approach used earlier for diagnostics — `/commit:apphost` writes a `` block directly into `applicationHost.config`, scoped to only this site, without touching the global `overrideModeDefault` (which would affect every site on the host) and without the earlier stray-element/relock failure mode. `tools/Setup-Site.ps1` now runs this automatically and is idempotent (re-running it is a no-op if already configured; verified by running it twice). + +**Verified, not assumed, that this doesn't open a traversal hole**: `enableParentPaths` only affects server-side `MapPath`/`#include` resolution of literal strings already in the script (here, the hardcoded `"../logs"` - never user input). It has no effect on how IIS resolves an incoming client URL. Confirmed directly: `GET /../Framework/Application.wsc`, `GET /..%2fFramework/Application.wsc`, `GET /..%252fFramework/Application.wsc`, and `GET /../logs/app.log` against the live site all returned `404`/`403` — IIS's own URL normalization and request filtering reject these independently of the ASP-level setting. + +**Full regression re-run after the restructure**: `tests/Test-Components.vbs` (WSH), `tests/Test-Http.ps1` (HTTP), and `tests/run-self-test.sh` (curl+JSON) all pass; `/hello` and `/self-test` work correctly; `logs/app.log` confirmed actually being written via the new parent-relative path (not just assumed - read back the full file content over SSH); `GET /Framework/Application.wsc` returns `404` (now because the path genuinely doesn't exist under the site root, not because of a `hiddenSegments` rule). diff --git a/docs/TEST-RESULTS.md b/docs/TEST-RESULTS.md index b7f822f..701c7e0 100644 --- a/docs/TEST-RESULTS.md +++ b/docs/TEST-RESULTS.md @@ -2,7 +2,7 @@ Host under test: `win2025test` (Tailscale 100.127.62.31), Windows Server 2025 Standard, build 10.0.26100, 64-bit. Access: SSH (key-based, `Administrator`) from the OpenClaw Linux host, driving `cscript`/`powershell` remotely. -Site: `WscMvc`, binding `*:8090`, physical path `C:\Projects\wsc-mvc`, app pool `WscMvc` (64-bit, no managed code). +Site: `WscMvc`, binding `*:8090`, physical path `C:\Projects\wsc-mvc\public` (updated 2026-09-19; project root through the M1/M2 sections below), app pool `WscMvc` (64-bit, no managed code). Date: 2026-09-19. ## M0 — Environment and feasibility @@ -176,3 +176,25 @@ Also verified `tests/run-self-test.sh` (`curl` + `python3 -m json.tool`, zero Wi ## M2 gate status: **PASS** The SPEC §13 M2 gate ("repeated/concurrent requests and failure cases behave predictably") was run on real Windows/IIS and holds unconditionally for HTTP response correctness. Four real defects were found and fixed while building this milestone (WSC property-syntax limitation, a locale-formatting bug, a `Randomize` collision, and a naive concurrent-logging approach that made things worse before a working lock-file mutex was verified) — see `docs/DECISIONS.md` for full detail on each. Proceeding to M3 (routing) is unblocked. + +## Restructure: `public/`-only webroot (2026-09-19) + +Commands run on `win2025test` after moving `Default.asp`/`web.config` into `public/` and updating `tools/Setup-Site.ps1`: + +``` +powershell -File tools\Setup-Site.ps1 +``` +Result: **PASS** — updated the site's `physicalPath` from the project root to `C:\Projects\wsc-mvc\public`, and set `enableParentPaths=true` scoped to just this site (`appcmd ... /commit:apphost`). Re-ran the same command a second time to confirm idempotency: reported "already exists with the correct physicalPath," no changes made the second time. **PASS** + +``` +cscript //nologo tests\Test-Components.vbs +powershell -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 +./tests/run-self-test.sh http://100.127.62.31:8090 (from the Linux host) +``` +All three: **PASS**, identical to pre-restructure output (the client-facing contract for `/hello` and `/self-test` is unchanged; only the physical layout changed). + +`Server.MapPath("../logs")` verified actually resolving and being written to — not just assumed from the setting being present — by reading back `logs/app.log`'s full content directly over SSH and confirming new entries appear after each request. **PASS** + +`GET /Framework/Application.wsc` -> `404`, now because the path is genuinely absent from the served tree rather than a `hiddenSegments` rule. **PASS** + +Path-traversal safety of `enableParentPaths` verified directly, not assumed: `GET /../Framework/Application.wsc`, `GET /..%2fFramework/Application.wsc`, `GET /..%252fFramework/Application.wsc`, and `GET /../logs/app.log` against the live site all returned `404`/`403` — confirms `enableParentPaths` (a server-side script `MapPath` setting) has no effect on how IIS resolves client-supplied URLs. **PASS** diff --git a/Default.asp b/public/Default.asp similarity index 87% rename from Default.asp rename to public/Default.asp index 0c2eed8..83e57d9 100644 --- a/Default.asp +++ b/public/Default.asp @@ -5,7 +5,10 @@ Dim route, httpMethod, logDir, ctx, app, statusLine, contentType, body route = Request.QueryString("route") httpMethod = Request.ServerVariables("REQUEST_METHOD") -logDir = Server.MapPath("logs") +' logs/ deliberately lives outside the served "public" webroot (IIS site +' physical path = public/), so a parent-relative MapPath is required here - +' requires enableParentPaths=true for this site. See docs/DECISIONS.md. +logDir = Server.MapPath("../logs") Set ctx = Nothing On Error Resume Next diff --git a/web.config b/public/web.config similarity index 64% rename from web.config rename to public/web.config index d34d4c8..afd6b8d 100644 --- a/web.config +++ b/public/web.config @@ -1,16 +1,18 @@ + - - - - - - - - diff --git a/tests/Test-Http.ps1 b/tests/Test-Http.ps1 index 90a2dab..ae56b1d 100644 --- a/tests/Test-Http.ps1 +++ b/tests/Test-Http.ps1 @@ -41,19 +41,22 @@ try { Report $false "GET /self-test request" $_.Exception.Message } +# Framework/ is a sibling of the "public" webroot, not a rule-denied +# subfolder within it - this checks it's genuinely unreachable, not just +# filtered. try { $wscResp = Invoke-WebRequest -Uri "$BaseUrl/Framework/Application.wsc" -UseBasicParsing - Report $false "GET /Framework/Application.wsc denied" "got $($wscResp.StatusCode)" + Report $false "GET /Framework/Application.wsc unreachable" "got $($wscResp.StatusCode)" } catch [System.Net.WebException] { $webResp = $_.Exception.Response if ($webResp) { $code = [int]$webResp.StatusCode - Report ($code -eq 404) "GET /Framework/Application.wsc denied" "got $code" + Report ($code -eq 404) "GET /Framework/Application.wsc unreachable" "got $code" } else { - Report $false "GET /Framework/Application.wsc denied" $_.Exception.Message + Report $false "GET /Framework/Application.wsc unreachable" $_.Exception.Message } } catch { - Report $false "GET /Framework/Application.wsc denied" $_.Exception.Message + Report $false "GET /Framework/Application.wsc unreachable" $_.Exception.Message } Write-Output "---" diff --git a/tools/Setup-Site.ps1 b/tools/Setup-Site.ps1 index b14977d..201f6a4 100644 --- a/tools/Setup-Site.ps1 +++ b/tools/Setup-Site.ps1 @@ -9,7 +9,10 @@ param( Import-Module WebAdministration if (-not $PhysicalPath) { - $PhysicalPath = Split-Path -Parent $PSScriptRoot + # IIS's site root is the "public" folder only - Framework/Controllers/ + # tests/tools/docs/logs are siblings of it, never served. See + # docs/ARCHITECTURE.md. + $PhysicalPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'public' } if (-not (Test-Path "IIS:\AppPools\$PoolName")) { @@ -25,9 +28,26 @@ if (-not (Test-Path "IIS:\Sites\$SiteName")) { New-Website -Name $SiteName -Port $Port -PhysicalPath $PhysicalPath -ApplicationPool $PoolName | Out-Null Write-Output "Created site $SiteName on port $Port -> $PhysicalPath" } else { - Write-Output "Site $SiteName already exists" + $existingPath = (Get-Website -Name $SiteName).PhysicalPath + if ($existingPath -ne $PhysicalPath) { + Set-ItemProperty "IIS:\Sites\$SiteName" -Name physicalPath -Value $PhysicalPath + Write-Output "Site $SiteName already existed; updated physicalPath $existingPath -> $PhysicalPath" + } else { + Write-Output "Site $SiteName already exists with the correct physicalPath" + } } +# Default.asp needs Server.MapPath("../logs") to reach logs/, which lives +# outside the "public" webroot by design. Scoped to just this site via +# /commit:apphost (writes a block in +# applicationHost.config) rather than unlocking system.webServer/asp +# machine-wide, which would affect every site on the host. See +# docs/DECISIONS.md for why the machine-wide unlock approach is avoided. +$appcmd = "$env:windir\system32\inetsrv\appcmd.exe" +& $appcmd set config $SiteName -section:system.webServer/asp "/enableParentPaths:True" /commit:apphost | Out-Null +$parentPathsNow = & $appcmd list config $SiteName -section:system.webServer/asp +Write-Output "enableParentPaths config: $parentPathsNow" + Start-WebAppPool -Name $PoolName -ErrorAction SilentlyContinue Start-Website -Name $SiteName -ErrorAction SilentlyContinue