Repository navigation
MCP and web host defaults: form sign-in, token length floor, sign-in wait, write-tool token gate - #5702
Conversation
…wait, write-tool token gate Web dashboard: a token is accepted only from the sign-in form (POST /auth/login); a token in the URL is no longer read. Failed sign-ins from one address wait longer each time (429 with Retry-After). web.network.trustedProxies names the proxies whose forwarded scheme and address are used; HSTS is sent on HTTPS responses. MCP host: a bearer token under 16 characters keeps the host loopback-only (still gated by that token), and keeps the write tools off. Wrong bearer tokens from one address wait longer each time. The server-tag tools are counted as write tools. add_servers refuses Windows authentication over an unverified connection unless mcp.allowWindowsAuthOverUnverifiedTls is set. Lite: mcp_allow_writes setting, the write policy and the unmute_analysis_finding tool. Compose files publish ports on loopback. Login hints, docs and tests describe the sign-in form.
…compared; sign-in POST origin check; body cap on bytes read; forwarded loopback is not loopback; IPv6 /64 grouping with a total budget; HSTS and log-level docs
…s screen; tool classification allowlist; write switch tests drive the host composition; token floor log omits the length
| _logger, "Web dashboard", DarlingRefusalGate.Token, StatusCodes.Status200OK, remote, | ||
| "the posted token does not match web.network.encryptedToken, so the login page was served instead", | ||
| now); | ||
| await WriteLoginPageAsync(context, oidcEnabled); |
There was a problem hiding this comment.
One mistyped token locks the operator into a sign-in loop.
This arm re-renders the login page as the response to POST /auth/login, so the browser's address bar is now /auth/login. The page's own script fills the hidden field from the current location:
document.getElementById('return').value = location.pathname + location.search + location.hash; // :2413
so the retry posts return=/auth/login. The retry with the correct token then runs line 2135, SanitizeRedirectPath("/auth/login") → /auth/login, and 302s there. /auth/login is now an auth-flow path in every mode (IsAuthFlowPath, :1528) and DecideWebRequest returns HandleAuthFlow before it looks at the session cookie (:1517-1523), so the GET lands back in HandleTokenSignInAsync, which on a non-POST writes the login form again (:2073) without ever checking for the cookie it just minted.
Failure scenario, start to finish:
- Open
http://host:5153/alerts, no cookie → login page at/alerts,return=/alerts. - Mistype the token →
POST /auth/login→ 200 login page, URL is now/auth/login. - Type the token correctly → 302
Location: /auth/loginwithSet-Cookie. GET /auth/login→ login form again. Repeat 3–4 forever.
The operator holds a valid session cookie the whole time and has no way to see it; the only escape is hand-editing the URL. A mistyped paste is exactly the case the new throttle is built around, so this is the common path, not a corner. No test covers it — every PostSignIn helper either omits return (→ /) or passes an explicit path (DarlingWebHostGateLiveTests.cs:231).
Two independent fixes, either of which closes it: have the GET leg of HandleTokenSignInAsync 302 to / (or to return) when TryValidateSessionCookie already passes, and/or have SanitizeRedirectPath's sign-in caller map an auth-flow path to / so a success never lands back on the form.
| continue; | ||
| } | ||
|
|
||
| if (IPNetwork.TryParse(entry, out var network)) |
There was a problem hiding this comment.
trustedProxies parses with bare IPNetwork.TryParse, so inet_aton spellings silently widen the trust list.
CidrAllowList exists because this repo already learned that IPNetwork.TryParse alone is not a CIDR reader — it follows inet_aton. The repo's own assertions prove it (Darling.Tests/DarlingCidrAllowListTests.cs:140-154):
Assert.Equal("192.168.8.0/24", IPNetwork.Parse("192.168.010.0/24").ToString()); // leading zero = octal
Assert.Equal("0.0.0.0/8", IPNetwork.Parse("10/8").ToString()); // short form zero-pads
Assert.Equal("10.0.0.0/8", IPNetwork.Parse("0x0A.0.0.0/8").ToString()); // 0x = hexCidrAllowList.TryParse guards against exactly those with IsPlainCidrText ("so the range written is the range enforced"), and refuses an IPv4-mapped entry. TryParseTrustedProxies calls neither, so:
"trustedProxies": ["192.168.010.0/24"]is accepted and honors forwarded headers from192.168.8.0/24— a block the operator never wrote, while the block they meant is not trusted."trustedProxies": ["10/8"]is accepted as0.0.0.0/8.
This list is strictly more dangerous than allowFrom: a source inside it can set X-Forwarded-For to any value, which this PR then feeds to IsInAllowFrom (DarlingWebHostService.cs:1555) and to DarlingSignInThrottle.SourceKey, i.e. it bypasses allowFrom and resets the sign-in wait per spoofed address; X-Forwarded-Proto from it also decides the cookie's Secure flag and HSTS. It fails open in the direction that matters, and the Critical line an operator would need never fires because the entry parses.
TryParseTrustedProxies_RefusesAnEntryItCannotRead only covers proxy.corp.local; nothing pins the spellings above. Routing each entry through CidrAllowList.IsPlainCidrText (and the same check on the bare-IP fallback at :310) would reuse the rule the rest of the host already enforces.
| if (!IsSameSiteSignIn(context.Request, publicHostName)) | ||
| { | ||
| refusals.Report( | ||
| _logger, "Web dashboard", DarlingRefusalGate.SignIn, StatusCodes.Status403Forbidden, remote, |
There was a problem hiding this comment.
This refusal is logged under the OIDC gate, on a dashboard that may have no OIDC at all.
DarlingRefusalGate.SignIn is the OIDC flow's gate — its doc comment says so, and DarlingHttpRefusalLog.Describe renders it verbatim as "OIDC sign-in flow (web.network.oidc)" (DarlingHttpRefusalLog.cs:36-43, 275). The refusal here is the shared-token form post's same-site check, which exists in every mode. An operator whose darling.json has no oidc block reads a Warning naming a gate they have not configured, on a class whose whole job is "which gate refused it, named the way an operator would have to name it to fix it".
It also shares the rate limiter's fold key. The budget is per (gate, source), so a scanner's cross-site posts and a genuine OIDC callback failure from the same address collapse into one bucket and one can hide the other — the precise reason ReadOnlySeat was given its own gate rather than reusing Token (DarlingHttpRefusalLog.cs:30-34).
A TokenSignIn member (or reusing Token, which is already what the other three refusals in this method use) would keep the label true and the fold key separate.
| before the token is compared: attempts staged on open connections and released together are | ||
| counted one by one, and only the allowance reaches the comparison. */ | ||
| var source = DarlingSignInThrottle.SourceKey(remote); | ||
| now = DateTime.UtcNow; |
There was a problem hiding this comment.
Non-blocking, but worth matching the MCP side: the web sign-in throttle reads DateTime.UtcNow directly, while DarlingMcpHostService.ConfigurePipeline took a Func<DateTime>? utcNow seam for exactly this ("A test passes a settable one so the wait can be crossed without sleeping"). DarlingWebHostService.ConfigurePipeline did not, so every web wait test is pinned to real elapsed time.
BaseDelay is 2 s, and RepeatedWrongTokens_FromOneSource_AreHeldWithRetryAfter_… / ARightToken_GivesBackOnlyItsOwnAttempt_… assert that two sequential PostSignIn calls land inside that window. In-process through TestServer that is normally microseconds, but a loaded runner that stalls either await past 2 s flips the expected 429 to a 302/200 — the #3676 shape in .github/REVIEW-TRAPS.md ("a test that reads the clock twice measures the CI scheduler"). Threading the same utcNow parameter through ConfigurePipeline → HandleAuthFlowAsync → HandleTokenSignInAsync would remove the dependency and also let a test cross the wait and assert it expires, which nothing does today.
There was a problem hiding this comment.
Verdict: CHANGES REQUESTED
Claims
- VERIFIED — A token is read only from
POST /auth/login;?token=is never compared —DarlingWebHostService.cs:1261passeshasValidToken: false;tokenInUrl(:1256) feeds onlyrefusals.Report. - VERIFIED — 3 free failures, 2 s doubling to 5 min, reserved-then-compared in one atomic step after the body is read —
DarlingSignInThrottle.DelayAfter/BeginAttempt(singlelock),HandleTokenSignInAsync:2103-2110;ManyConcurrentWrongTokens_…(staged)pinsFreeFailures + 1compared out of 40. - VERIFIED — A success returns only its own reservation; IPv6 by /64; 60/hour across all sources —
Accepted,SourceKey,GlobalMaxAttempts;ARightToken_GivesBackOnlyItsOwnAttempt_…. - VERIFIED — Same-site check precedes the body read and the count, and the login page sets
<meta name='referrer' content='same-origin'>— :2078 before :2089/:2108; meta at :2369. - VERIFIED — 4 KB cap on bytes read, url-encoded only, multipart unread —
ReadLoginFormAsync/IsUrlEncodedForm;ABodyOverTheCap_…,AMultipartBody_IsNotReadAtAll. - VERIFIED — Only the connection's own address can be loopback, and
trustedProxiesparses before the certificate loads —DarlingConnectionAddressStamp+IsLoopbackConnection(:1573); parse at :634,DarlingListenerTls.Resolveat :664. - VERIFIED — A sub-16-character token keeps either host loopback-only while still gating it, and leaves MCP writes off —
ApplyTokenFloorat :517 withrequireTokenWhenLoopbackOnlyrecomputed at :858,DarlingMcpHostService.cs:441,DarlingMcpWritePolicy.ResolveForStart. - VERIFIED — Server-tag tools are writes;
edit_serveris a server-admin write applying theadd_serversscreen before any probe;add_serversscreens before the store opens —DarlingMcpWritePolicy.WriteTools/ServerAdminTools,Edit.cs:347-356(insideNeedsProbe, whichconnectionChangedsets on any auth/encrypt/trust change, :940-1009),DarlingMcpServerAdminTools.cs:225. - VERIFIED — Lite gains
mcp_allow_writes, a write policy andunmute_analysis_finding; the count is 90 — 90McpServerTool(Nameattributes underLite/Mcp/;McpSettings.cs:142-150kind-checks the key;FindingStore.UnmuteStoryAsyncis the oneAcquireWriteLocksite andFindingStoreTestspins it there with its reason. - VERIFIED — Compose publishes both ports on the docker host's loopback and narrows
allowFromoff0.0.0.0/0—compose/docker-compose.yml:162-163,compose/darling.sample.json. - UNVERIFIED — Every test-plan count (2,666 / 1,517 / 827 / 496 / 376 / 3,316 / 86 run, 0 failed) and "0 warnings, 0 errors" — no Windows/.NET 10 build or xUnit runner on this tree.
- UNVERIFIED — "measured 95,222 bytes (dev 94,429 + 793)" — that is
McpToolsListBudgetTests.Measure()output; the constant and the pinned per-tool bytes agree with each other, which is not the measurement. - UNVERIFIED — "under the response header's
no-referrerpolicy a browser sendsOrigin: null", and that the<meta name=referrer>overrides the header — browser behavior;TheLoginPage_KeepsTheRealOriginOnItsOwnPostcan only assert markup. If the header wins in a shipping browser,IsSameSiteSignIn403s every browser sign-in. - UNVERIFIED — HSTS "uses HTTPS for every port of that same name for a year" — correct per RFC 6797 on paper, but a live browser behavior; the test pins the header value and the wording only.
- REFUTED — "the sign-in leg 302s back to the page the form was covering", as
Darling/README.mdanddocs/uat-onboarding.mdnow promise operators — true only on a first-try success. After a wrong token the form is re-served as the response toPOST /auth/login(:2129), the page's own script (:2413) then fillsreturnwith/auth/login, and the correct retry 302s there — a pathIsAuthFlowPath(:1528) routes toHandleTokenSignInAsyncahead of the cookie check (:1517-1523), so the form renders again and the operator never lands. Inline comment on :2129.
Riskiest lines
Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs:2129— re-rendering the form at/auth/loginis what makesreturnself-referential and loops every later correct sign-in — UNPINNED (everyPostSignInhelper omitsreturnor passes an explicit path,DarlingWebHostGateLiveTests.cs:231)Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingHostBinding.cs:306— the trusted-proxy list is read with bareIPNetwork.TryParse, skipping theIsPlainCidrTextrule the rest of the host enforces; a trusted source'sX-Forwarded-Foris whatallowFromand the throttle then judge — UNPINNED (TryParseTrustedProxies_RefusesAnEntryItCannotReadcovers only a hostname)Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs:1261—hasValidToken: falseends the URL-token exchange for every bookmark, script and sign-in link in the field, and leavesSetCookieAndRedirectdead — pinned byGetOnTheSignInPath_RendersTheForm_AndAUrlTokenThereIsNotExchangedandDarlingWebAuthTests
Traps near this diff
- "Lite and Darling ship in parity (CONTRIBUTING's two-store rule): a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit" — does not apply —
unmute_analysis_findingis Lite-only on purpose (Darling's MCP role holds INSERT-only on that table:DarlingManagedRoles.cs:1266,provision-roles.sql:709), the reason is inline inKnownDarlingMissingMcpTools, and the newLiteOnlyMcpTools_MatchTheAllowListholds the carve-out to an exact set. The write switch itself landed on both SKUs. - "A test that reads the clock twice measures the CI scheduler … One anchor owns both ends of an interval" (#3676) — applies — the MCP host took a
Func<DateTime>? utcNowseam,DarlingWebHostService.ConfigurePipelinedid not, so :2061/:2107 read the real clock and the web wait pins need two sequentialTestServercalls inside a real 2-second window. Non-blocking; inline comment on :2107. - "A pin that encodes the lie … Ask what a pin would fail ON" (#3699) — does not apply —
DarlingWebSignInHardeningTests.cs:426fails ifBeginAttemptstops being atomic and :461-464 fails ifAcceptedclears more than one reservation; neither asserts its own premise. - "A roster-based census does not see the file it does not roster … An anti-pattern census sweeps the class" (#3699) — does not apply —
McpWritePolicyTests/DarlingMcpWritePolicyTestsenumerate every registered tool from the host's own registration and fail on any name in neitherWriteToolsnorMcpToolClassification.ReadOnlyTools. - "
.gitattributessaystext=auto eol=crlf… A diff that touches every line of a file is a line-ending conversion until proven otherwise" — does not apply — the one whole-block rewrite,Lite/Mcp/McpHostService.cs, is a real extraction: the deleted lines reappear verbatim inRegisterMcpServer(Lite/Mcp/McpHostService.cs:154-233).
Divergence (blind read vs PR body)
Three places where the accounts part.
-
Where a success lands. The body details the wait, the counting and the 429 but never says where a success goes; the README and
docs/uat-onboarding.mdthis PR rewrites both promise the page you were opening. The diff delivers that only on a first-try success. After any miss the operator is looped on/auth/login— the one path that renders the login form regardless of a valid session cookie — and a mistyped paste is the exact case the throttle exists for. Inline on :2129. -
What "an entry that does not parse" covers for
trustedProxies. True for entriesIPNetwork.TryParserejects. Unsaid: this parser accepts spellings resolving to a different network than the one written — the repo's ownDarlingCidrAllowListTests.cs:140-154asserts192.168.010.0/24→192.168.8.0/24and10/8→0.0.0.0/8, which is whyCidrAllowListhasIsPlainCidrTextandallowFromrefuses them. Inline onDarlingHostBinding.cs:306. -
The gate the cross-site refusal logs under. Unmentioned in the body; the diff reuses
DarlingRefusalGate.SignIn, whichDescriberenders as "OIDC sign-in flow (web.network.oidc)" and which shares the limiter's(gate, source)fold key with real OIDC refusals. Inline on :2083.
Everything else matched what I found before reading the body, including the parts I expected to be overstated: the reserve-before-compare really is one lock, the add_servers screen really runs before the store opens, and the write switch really landed on both SKUs.
Reviewer checklist
- In a real browser on an exposed dashboard: open a deep link, mistype the token once, then enter it correctly, and confirm you reach the page rather than the form again. Repeat with OIDC on.
- In Chrome and Firefox, confirm the form's POST carries a real
Origin(notnull) — i.e. the meta tag beats theReferrer-Policy: no-referrerheader. If it does not, every browser sign-in 403s. - Behind an actual TLS-terminating proxy in
trustedProxies:Securecookie,https://OIDCredirect_uri, HSTS present,allowFromjudging the forwarded client, and a direct connection's spoofedX-Forwarded-Forignored. - Run the suite on Windows/.NET 10 for the test-plan counts, the 95,222-byte
tools/listmeasurement, and the live classes skipped here. - Upgrade a Darling install that used the MCP write tools: the Information line at start, the tools returning once
mcp.allowWrites(+mcp.allowServerAdmin) and a 16+ character token are set, and a pre-existing short token degrading the listener rather than stopping the service.
…each other, a source that presented the right token is not held by the total budget, every loopback address is one source, and the total-budget 429 says so
| var remaining = _inFlightWaitLimit - Stopwatch.GetElapsedTime(started); | ||
| if (remaining <= TimeSpan.Zero) | ||
| { | ||
| return SignInAttempt.Held(BaseDelay, SignInHold.Source); |
There was a problem hiding this comment.
Non-blocking observation: on the InFlightWaitLimit timeout path, the attempt is always held for a flat BaseDelay (2s) with SignInHold.Source, regardless of how large entry.Misses + entry.InFlight has actually grown (the value Decide used just above to compute until, at line 189-190). If several in-flight reservations from the same source stack up past FreeFailures + 1 and then genuinely stall (thread-pool starvation, a hung comparison), every attempt that times out here reports a 2-second Retry-After while the real per-source backoff for that failure count (DelayAfter(entry.Misses + entry.InFlight)) would be larger. The client is told to retry sooner than the state actually allows, so it just loops back into another 5-second Pending wait. No reservation or comparison is affected (state isn't touched here), so this is a cosmetic Retry-After inaccuracy in a rare stall scenario, not a security or correctness issue — flagging since none of the new tests exercise entry.Misses + entry.InFlight above FreeFailures + 1 at the point InFlightWaitLimit expires.
There was a problem hiding this comment.
Verdict: LGTM
Claims
- VERIFIED — "parallel requests from one client wait for each other" instead of 429ing: in-flight reservations gate a new attempt into
SignInAttempt.Pending()(DarlingSignInThrottle.csDecide, theentry.InFlight > 0branch), andBeginAttemptAsyncloops on it, bounded byInFlightWaitLimit— pinned bySixteenConcurrentRightTokens_AreAllServedandSixteenConcurrentRightTokens_FromOneSource_AllSignIn(staged and unstaged). - VERIFIED — "a source that presented the right token is not held by the total budget" —
AcceptedcallsRememberSuccess, andDecide's total-budget check is skipped when_recentSuccess.ContainsKey(source)— pinned byASourceWithARecentSuccess_IsNotHeldByTheTotalBudget_ButStillByItsOwnWaitand the MCP/webOtherSourcesSpendingTheTotalBudget_DoNotStop…integration tests, which also confirm the source's own wait still applies afterward. - VERIFIED — "every loopback address is one source" —
SourceKeynow returnsLoopbackKeyforIPAddress.IsLoopback(ip)before the /64 branch — pinned byEveryLoopbackAddress_IsOneSource(unit) andEveryLoopbackAddress_ShareOneWait_OnTheLoopbackBind(MCP integration, four spellings including an IPv4-mapped IPv6 one). - VERIFIED — "the total-budget 429 says so" —
SignInHold.Totalvs.Sourceselects distinct message text in bothDarlingMcpHostService.csandDarlingWebHostService.cs("not necessarily this one" / "No token is compared from an address…" vs "from this address…") — pinned by the body-substring assertions in both newOtherSourcesSpendingTheTotalBudget_…tests. - VERIFIED — README wording was updated to match (in-flight wait, four-compared-per-address ceiling, loopback folding, the recent-success carve-out, the total-hold explanation) —
Darling/README.mddiff reads consistently with the code above.
Riskiest lines
Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingSignInThrottle.cs:202—BeginAttemptAsync'sInFlightWaitLimittimeout path returns a flatHeld(BaseDelay, Source)rather thanDelayAfter(entry.Misses + entry.InFlight)— UNPINNED for the case where that sum has grown pastFreeFailures + 1by the time the wait times out (cosmeticRetry-Afterunderstatement only; inline comment posted).Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingSignInThrottle.cs:173-213(Decide) — the singlelock-guarded state machine now carries three outcomes (held/pending/reserved) plus in-flight bookkeeping shared byAccepted/Missed/Signal— pinned thoroughly byDarlingSignInThrottleTests' new concurrent-async tests (ManyConcurrentAsyncAttempts_AllMissing_…,…AllMatching_…,AMixWithinTheAllowance_…,AnAttemptOnlyHeldUpByReservationsInFlight_…,AnAttemptWaitingOnReservationsInFlight_…,TheWaitOnReservationsInFlight_IsBounded_AndCancellable).Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs:1787/DarlingWebHostService.cs:1335— switching the gate toawait BeginAttemptAsync(...)means a client'sRequestAbortedcan now surface asOperationCanceledExceptionfrom inside what used to be a synchronous, non-awaiting check — pinned byTheWaitOnReservationsInFlight_IsBounded_AndCancellableat the throttle level; no new MCP/web integration test cancels mid-wait, but Kestrel's standard handling of request-aborted cancellation elsewhere in this codebase makes this low risk.
Traps near this diff
- "Lite and Darling ship in parity (CONTRIBUTING's two-store rule): a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding" — does not apply —
DarlingSignInThrottleand the bearer/sign-in gates it backs are Darling-only surfaces (Lite is the WPF desktop app with no networked MCP/web sign-in;grepforSignInThrottle/BeginAttemptunderLite/finds nothing), so there is no Lite counterpart for this change to diverge from. - "A test that reads the clock twice measures the CI scheduler … One anchor owns both ends of an interval" (#3676) — does not apply to the new tests — the new throttle-level tests drive time through the injected
Clock/fixedT0, and the new async in-flight tests (Task.Delay(50),WaitAsync(TimeSpan.FromSeconds(10))) assert only "not yet completed" / "eventually completes", not a value computed from two separate real-clock reads.
Divergence (blind read vs PR body)
None — the commit messages ("parallel requests from one client wait for each other", "a source that presented the right token is not held by the total budget", "every loopback address is one source", "the total-budget 429 says so") match the diff and the test coverage on every point graded above. This follow-up does not touch the three issues raised in the prior full review (the /auth/login redirect loop, the trusted-proxy CIDR parsing, and the URL-token exchange) — none of the lines involved in those findings are part of this diff.
Reviewer checklist
- Run
Darling.Tests(the three throttle/bearer/sign-in classes touched here) on the real CLR: the new concurrent-async tests use real thread-pool scheduling andTask.Delay/WaitAsynctiming that a read-only tree cannot execute. - Under real load, confirm the
InFlightWaitLimit(5s) default doesn't hold HTTP connections open long enough to matter for the web dashboard's or MCP client's own request timeout, now that a burst of same-source requests can block inside the gate instead of returning immediately.
… IDN host names compare equal; HSTS is opt-in; the token floor needs six different characters A POST with Sec-Fetch-Site same-origin passes the sign-in origin check, cross-site and a null Origin without it are refused, and a browser without the header is judged by Origin or Referer against the request host, the forwarded host of a trusted proxy or web.publicBaseUrl. Host names compare in their ASCII form. The 403 text and the README name web.publicBaseUrl. web.network.hsts turns Strict-Transport-Security on. A test drives the edit_server tool method with the default policy.
…a timed-out in-flight wait holds for the source's own delay
…e-site refusal has its own gate; the web wait reads an injectable clock; a token in the address has no exchange path left; README sign-in text matches the code
…le argument by name The bundle's config shape now lists mcp_allow_writes, mcp_allow_server_admin and mcp_allow_windows_auth_over_unverified_tls (all booleans), and the three properties are classified as projected. The Lite pin that checks the app hands the MCP host its schedule now matches _scheduleManager as an argument wherever it sits in the constructor call, since the call gained an argument after it.
What changes
Web dashboard
POST /auth/login). A token in the address (?token=) signs no one in and is never compared, whatever its value. Without a session cookie, the request gets the sign-in page (an/api/path gets401), and the service log line says a token in the URL is not accepted and that scripts sign in with a form POST to/auth/loginand then present the session cookie. The dashboard takes no bearer header (the MCP endpoint does).WebAuthActionandWebRequestActionno longer haveSetCookieAndRedirect,DecideWebAuthandDecideWebRequesttake no token verdict, andBuildPathWithoutTokenis gone. A token is never an input to the decision functions; the form's POST is handled as a flow route before them./auth/loginand then presents the session cookie it gets back (curl -c jar -d "token=<token>" http://<host>:5153/auth/login, thencurl -b jar http://<host>:5153/api/fleet).returnfield (the address bar then names/auth/login, not the page), so a right token on the next try lands where a first-try success does. The landing isSignInLandingPath: a local path only, and a target that is a sign-in or sign-out path lands on/. The page script fills the field from the address only when the address is not/auth/login, and the SSO link takes the same target.429withRetry-After, a correct token included. The wait is kept in memory, so a service restart clears it; the429page and the README say so, and an operator held out by a wait can restart the service.429. If they match, their reservations are given back and it goes through. If they do not match, their misses are recorded, the wait starts and it gets429without a comparison. If the 5 seconds pass with them still unresolved, it gets429withRetry-Afterequal to the delay the source's recorded misses plus those still being checked earn (DelayAfter(Misses + InFlight)), which is the real hold. At most 4 wrong tokens (the 3 free ones plus the one that starts the wait) are compared per address before its wait starts, however many requests arrive at once.127.0.0.0/8,::1, IPv4-mapped loopback) is one source, and a total budget of 60 attempts per hour holds across all sources that have not signed in during the last hour, so changing addresses does not multiply the attempts. An address that signed in during the last hour is not held by the total budget; its own wait still applies. The hour matches the window the total is counted over, a success renews it, and at most 256 such addresses are remembered. All these numbers are constants inDarlingSignInThrottle, each with the reason beside it; there is no setting.429page and the refusal line say so: the wrong tokens came from other addresses, a service restart clears the hold, and the failing client can be fixed or taken out ofallowFrom.Originheader (or itsReferer, when there is noOrigin) must name the host the request was addressed to, port included, orweb.publicBaseUrl's host;Sec-Fetch-Site: cross-siteand anOriginthat is not a URL (null) are refused with403. This is checked before the body is read and before anything counts as an attempt. A request with neither header (a script) passes. The sign-in page sets<meta name="referrer" content="same-origin">, because under the response header'sno-referrerpolicy a browser sendsOrigin: nullwith the form's post.TokenSignInSite. Its log line reads "same-site check on the token sign-in form (set web.publicBaseUrl behind a proxy that rewrites Host)", it no longer reads as the OIDC flow, and its rate-limit key is separate from the OIDC flow's and from the wrong-token gate's.Sec-Fetch-Site: same-originis accepted. This keeps sign-in working behind a proxy that rewritesHostwhenweb.publicBaseUrlis not set. A POST without that header is judged by itsOriginorRefereragainst the request host, theX-Forwarded-Hostof a trusted proxy, orweb.publicBaseUrl. The403text and the README nameweb.publicBaseUrl.application/x-www-form-urlencodedis read; a multipart body is refused unread.ConfigurePipelinetakes an optionalutcNowclock for the sign-in wait, as the MCP host does. The web wait tests use a settable clock, so they cover the wait expiring without sleeping.trustedProxiesset, only the address of the connection itself can be loopback forallowFrom. A forwarded127.0.0.1or::1is tested againstallowFromlike any other address, and a proxy on this host that forwards a client outsideallowFromstill gets403. The proxy must append the client's address toX-Forwarded-Foror overwrite it, andtrustedProxiesmust not cover clients that connect directly.web.network.trustedProxieslists the proxies whose forwarded scheme and address are used. The list is parsed before a certificate is loaded, so a bad entry never publishes one. A proxied HTTPS sign-in gets aSecurecookie.trustedProxiesentries follow the same plain-text rule asallowFrom(CidrAllowList.IsPlainCidrText). An entry is an IP or a CIDR written as four decimal numbers 0-255 with no leading zero, or an IPv6 address. An entry whose written form names a different network than the parser would read (192.168.010.0/24,10/8) is refused, and so is a bare IP written that way (010.0.0.5,10.1). Plain entries (10.0.0.5,10.0.1.0/24,::1) are accepted as before. The refusal names the entry.Strict-Transport-Securityis sent on responses to HTTPS requests only whenweb.network.hstsis true (it is off by default). It is kept per host name and ignores the port: once a browser has reached the dashboard over HTTPS by name, it uses HTTPS for every port of that same name for a year. The code comment and the README say so.--configure-networkhints,--print-web-tokentext,darling.sample.json, the Darling README anddocs/uat-onboarding.mddescribe the sign-in form instead of a URL token. In the Darling README, the web sign-in text is one paragraph that matches the code, the MCP certificate andallowFromparagraphs are back in the MCP section, and the two compose paragraphs are restored.MCP host
mcp.allowWriteson, such a token leaves the write tools off.429/Retry-Aftershape as the dashboard. A request with no bearer header, or with one that is not a bearer, does not count and does not take part in the wait.429. A request that is held up only by requests from the same address that are still being checked (a client that sends several requests at once) waits for them, at most 5 seconds, instead of getting429: parallel requests with the right token are all served, and if the ones it waited on were wrong, it is held without a comparison. During the wait no token is compared, on the loopback bind too (there every local client is one address, and every loopback address,127.0.0.0/8and::1, is that one address), and a correct token is refused with the rest. A success gives back only its own attempt. The wait is kept in memory: a service restart clears it, and the429text and the README say so. IPv6 sources are grouped by /64 and the same total budget applies, to sources that have not presented a correct token during the last hour; a source that did is not held by the total budget, and its own wait still applies. When the total budget holds, the429text and the refusal line say so and say that a service restart clears it. The MCP host reads no forwarded headers, so behind a proxy every client is one address; the README says so.create_,update_,delete_,assign_,unassign_server_tag) are in the write-tool set, so they are served only when writes are on. Every registered tool is classified: the write-policy tests list all registered tools and fail unless each is in the write set or on an explicit read-only list, so a new tool fails until someone classifies it. Every write tool carriesreadOnlyHintfalse anddestructiveHinttrue; every other tool carriesreadOnlyHinttrue.edit_serveris a write tool and a server-admin tool: it is served only withmcp.allowWritesandmcp.allowServerAdmin, likeadd_serversandremove_server. Before it probes, it applies theadd_serversscreen to the definition as it would be saved, so a change that points Windows authentication at an unverified connection is refused (unlessmcp.allowWindowsAuthOverUnverifiedTlsis true) and nothing is probed or saved. The dashboard's edit route passes no policy.ResolveForStart,RequireTokenWhenLoopbackOnly,ApplyTokenFloor,CreateHostBuilder) that the host and the tests both call. The log for a token under 16 characters says it is shorter than the minimum and no longer gives its length. The README gives a PowerShell command that makes a strong token with a cryptographic random generator.add_serversrefuses Windows authentication over an unverified connection (trust server certificate, orOptionalencryption) unlessmcp.allowWindowsAuthOverUnverifiedTlsis true. The refusal happens before the store or the server is touched.add_servershas an overload for callers that are not MCP clients; the dashboard's add route uses it.mcp_allow_writes,mcp_allow_server_adminandmcp_allow_windows_auth_over_unverified_tls(booleans only), and the reflection test that classifies every config property lists them as projected.Lite
mcp_allow_writessetting, a write policy and theunmute_analysis_findingtool (served only when writes are on). Tool counts in the instructions, README andllms.txtare 90 (Lite) and 181 (Darling).mcp_allow_writescomment insettings.sample.jsonand the README say that turning it on lets any program on this computer mute or unmute findings. Lite's host registration (RegisterMcpServer) is one method the tests also call.Compose and docs
Already on dev
These protections already exist on dev, so dev's version is kept and nothing is carried:
frame-ancestors 'none',X-Frame-Options,nosniff,Referrer-Policy). Only HSTS is added, inside dev's middleware.add_serversgeneric connect-failure reply, with the driver text in the service log.env:/file:references refused from web and MCP password fields; password taken as given.darling.sample.jsonnow say Warning instead of Critical).Test plan
Latest run, at
c3bd70ced:DiagnosticsBundle*classes: 215 total, 26 skipped (live), 0 failed;*Census*500 and*Ratchet*15, 0 failed;-trait Stage=Guard1,517 passed. Lite:CollectionHealthRunTimeToolTests19 passed (it fails with_scheduleManagerremoved from the host call), Census 67,-trait Stage=Guard496 passed.Earlier run, at
9b9a3c8e8:*Census*and*Ratchet*class in Darling.Tests: 515 total, 1 skipped, 0 failed.-trait Stage=Guard: 1,517 passed.ServerEditLiveTestson a throwaway local PostgreSQL: 11 total, 1 skipped (the one that needs a real SQL Server), 0 failed.McpWritePolicy, Census and Ratchet classes: 79 passed.-trait Stage=Guard: 496 passed.AWrongTokenThenTheRightOne_LandsWhereAFirstTrySuccessWould, 5 cases: a wrong token, a second wrong token, then the right one lands where a first-try success does) was checked against the old code. With thereturnPathpass toWriteLoginPageAsyncand the page-script condition removed, all 5 cases fail (the form carries an emptyreturn); restored, all 5 pass.ServerEditLiveTests,Installer.Tests, the full suite. CI decides.Earlier runs, before
f964fa391and9b9a3c8e8(counts there are older than the lines above):Darling.TestsandLite.Testsbuild with 0 warnings, 0 errors.*Census*and*Ratchet*class, the MCP and web host classes, compose and config pins): 2,666 run, 0 failed, 293 skipped (live classes need a database).*Mcp*,*Census*,*Ratchet*, inventory pins: 827 run, 0 failed.unmute_analysis_finding); the ceiling inMcpToolsListBudgetTestsis 95,222.DarlingSignInThrottleTests,DarlingMcpBearerThrottleTests,DarlingWebSignInHardeningTests,DarlingWebHostGateLiveTests,DarlingWebAuthTests,DarlingHttpRefusalLogTests,DarlingHostBindingTests): 408 run, 0 failed (repeated 5 times, same result). They include a 500-way and a 64-way and a 16-way concurrent burst that reserve exactly the allowance, 16 concurrent right tokens that are all served (MCP on both binds, and the dashboard sign-in plain and staged), a mix of right and wrong tokens (14 right with 2 wrong are all decided as sent; 8 and 8 compare exactly 4 wrong ones), the wait on in-flight reservations (admitted when one matches, held without a comparison when they miss, bounded, cancellable), sources that spent the total budget not stopping a source with a recent success on MCP and on the dashboard (whose own wait still holds it), the expiry and bound of that memory, 127.0.0.1, 127.0.0.2,::1and mapped loopback sharing one source, the total-budget429text, a staged burst of 40 held open and released together, the origin check, the chunked and multipart body cases, a forwarded loopback address, the /64 grouping, the total budget, and pins for the compose level and the HSTS wording.create_mute_ruleandedit_serveron/and/core, Lite's mute and unmute, are refused as unknown tools; a weak or missing token leaves writes off),edit_serverscreen and token-floor decision tests: Darling write-policy, edit-core, bearer-throttle and host-binding classes plus Census, Ratchet, tool-list budget andStage=Guardclasses: 3,316 run, 0 failed, 329 skipped (live). Lite Mcp write-policy, Census, Ratchet, budget classes: 86 run, 0 failed;Stage=Guard496 run, 0 failed.*Census*and*Ratchet*515 run, 0 failed, 1 skipped; the MCP and web host classes, tool-list budget and instructions-budget classes together with those and the throttle classes: 1,127 run, 0 failed, 1 skipped;-trait Stage=Guard1,517 run, 0 failed.DarlingWebSignInHardeningTests59 run, 0 failed.ServerEditLiveTests11 run, 0 failed, 1 skipped on a throwaway local PostgreSQL; the new tool-path test fails whenpolicy: policyis dropped at either call and passes when restored. Edit-core, route, tools, Census, Ratchet,Stage=Guardfilters together: 418 run, 0 failed.Changelog
SECTION: Changed
ENTRY:
/auth/login. A token in the address (?token=) is never compared: a bookmark or link that carries one opens the sign-in form, and the service log says why. A script posts the token to/auth/loginand then presents the session cookie it gets back. A wrong token keeps the page you were opening, so a right token on the next try lands there. A token under 16 characters, or with fewer than 6 different characters, keeps either host loopback-only. Repeated wrong tokens from one address wait longer (429 with Retry-After).trustedProxiessupports HTTPS behind a proxy and accepts only plain IP or CIDR text, andweb.network.hststurns HSTS on. The server-tag tools are write tools.add_serversandedit_serverask formcp.allowWindowsAuthOverUnverifiedTlsbefore accepting Windows authentication over an unverified connection, andedit_serveris a server-admin write tool that applies the same check. Lite gainsmcp_allow_writesandunmute_analysis_finding.REF:
[MCP and web host defaults: form sign-in, token length floor, sign-in wait, write-tool token gate #5702]: MCP and web host defaults: form sign-in, token length floor, sign-in wait, write-tool token gate #5702