浏览代码

Restructure: public/-only IIS webroot, physical separation over hiddenSegments

At Daniel's explicit direction (overrides SPEC.md $5's originally fixed
tree, updated in place per AGENTS.md precedence rules):

- Default.asp + web.config moved into public/; Framework/Controllers/
  tests/tools/docs/logs are now siblings entirely outside the served root
  - physical separation, not a web.config rule, is what protects them now
- IIS site physicalPath changed to C:\Projects\wsc-mvc\public
- enableParentPaths enabled, scoped to just this site via
  `appcmd ... /commit:apphost` (writes a <location> block in
  applicationHost.config, not a machine-wide unlock of the locked
  system.webServer/asp section)
- Default.asp now uses Server.MapPath("../logs") to reach logs/
- tools/Setup-Site.ps1 automates and reconciles all of the above,
  verified idempotent (safe to re-run)
- Verified enableParentPaths does not open a client-side traversal hole:
  direct/encoded/double-encoded ../ URL attempts all correctly 404/403
- Full regression re-run (WSH, HTTP, curl+JSON self-test) all pass
  unchanged from the client's point of view
- SPEC.md, docs/ARCHITECTURE.md, docs/DECISIONS.md, docs/TEST-RESULTS.md
  updated to reflect the new structure as current, not left stale
master
Bottybotsterson 2 周前
父节点
当前提交
9a28c929ed
共有 8 个文件被更改,包括 99 次插入 和 25 次删除
  1. +12
    -3
      SPEC.md
  2. +5
    -4
      docs/ARCHITECTURE.md
  3. +16
    -2
      docs/DECISIONS.md
  4. +23
    -1
      docs/TEST-RESULTS.md
  5. +4
    -1
      public/Default.asp
  6. +10
    -8
      public/web.config
  7. +7
    -4
      tests/Test-Http.ps1
  8. +22
    -2
      tools/Setup-Site.ps1

+ 12
- 3
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


+ 5
- 4
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 `<location path="WscMvc">` 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)



+ 16
- 2
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 `<asp .../>` 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 `<location path="WscMvc">` 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).

+ 23
- 1
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**

Default.asp → 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

web.config → public/web.config 查看文件

@@ -1,16 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<!--
No hiddenSegments needed here: Framework/, Controllers/, tests/,
tools/, docs/, and logs/ all live as siblings OUTSIDE this "public"
folder, which is IIS's entire site physical path. There is no code
path by which IIS could serve them, regardless of web.config -
physical separation, not a filtering rule, is what protects them. See
docs/ARCHITECTURE.md / docs/DECISIONS.md. The extension denylist below
is kept only as cheap defense-in-depth against a stray file someday
landing directly inside public/ by mistake.
-->
<security>
<requestFiltering>
<hiddenSegments>
<add segment="Framework" />
<add segment="Controllers" />
<add segment="tests" />
<add segment="tools" />
<add segment="docs" />
<add segment="logs" />
</hiddenSegments>
<fileExtensions>
<add fileExtension=".wsc" allowed="false" />
<add fileExtension=".vbs" allowed="false" />

+ 7
- 4
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 "---"


+ 22
- 2
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 <location path="SiteName"> 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



正在加载...
取消
保存

Powered by TurnKey Linux.