Plan step 3 of the multi-server build, and the first half of audit finding C9: an accepted untrusted certificate is an exception for named origins, not for every host.
What changes
A certificate exception names at most two origins: the print server's, and the sign-in provider's when it is a different host. An untrusted certificate is accepted only when the request's own origin is one of them. Before, one switch accepted an untrusted certificate from any host.
An address with no nameable origin is a refusal. That includes a sign-in provider's discovery address: it is refused with a session error, and the compatibility check no longer offers browser sign-in for such an issuer.
A refused certificate is remembered with its origin and the time, public certificate data only.
Log lines show a host in one form, its ASCII name. When an address is shown, anything left out is named by kind ("query omitted", "user information omitted", "fragment omitted") and its content is never logged. A refused provider endpoint names the rule that failed.
Behaviour to know about
A sign-in that worked only because every host's certificate was accepted now fails at the provider unless the provider's origin is in the exception. That is the intended change.
A server typed with a non-ASCII name is shown by its ASCII name in lines that describe a whole address.
Verification
Head ca408e4 on release/2.0 at fe95a9f.
build/Verify.ps1 passed on that head: 1,098 Core, 1,568 Windows runtime and command line, 360 presentation tests, none failed; Core 100% of 2,246 lines; presentation 100% of 936 lines; coverage gate passed.
Review (independent reviewer, three passes)
6c9f67d: two should-fix (a discovery address with no nameable host escaped as an unhandled exception; the provider's host was shown as the server wrote it in several log lines), nits. The reviewer probed about 70 hosts and found no input that made an exception cover a host other than the one the connection really goes to.
e28af87: one new should-fix (a log line had stopped showing why a provider endpoint was refused), nits.
ca408e4, the head merged: Verdict on ca408e4: 0 blockers, 0 should-fix, 4 nits. The reviewer checked rule by rule that the address check accepts exactly what it accepted before, and that the only audit row this change touches is C9.
Left open, to be recorded in the audit (nits)
The typed origin is still compared as typed in ServerTrust; the Origin request header goes out as typed; a typed host with no ASCII name is accepted by the constructor and then fails every request as cross-origin.
An IPv6 zone is kept for matching and dropped from display.
The two provider sign-out refusals do not name the rule that failed.
The second half of C9 (the discovery address is not bound to the issuer) is not part of this change.
Plan step 3 of the multi-server build, and the first half of audit finding C9: an accepted untrusted certificate is an exception for named origins, not for every host.
**What changes**
- A certificate exception names at most two origins: the print server's, and the sign-in provider's when it is a different host. An untrusted certificate is accepted only when the request's own origin is one of them. Before, one switch accepted an untrusted certificate from any host.
- An address with no nameable origin is a refusal. That includes a sign-in provider's discovery address: it is refused with a session error, and the compatibility check no longer offers browser sign-in for such an issuer.
- A refused certificate is remembered with its origin and the time, public certificate data only.
- Log lines show a host in one form, its ASCII name. When an address is shown, anything left out is named by kind ("query omitted", "user information omitted", "fragment omitted") and its content is never logged. A refused provider endpoint names the rule that failed.
**Behaviour to know about**
- A sign-in that worked only because every host's certificate was accepted now fails at the provider unless the provider's origin is in the exception. That is the intended change.
- A server typed with a non-ASCII name is shown by its ASCII name in lines that describe a whole address.
**Verification**
- Head `ca408e4` on `release/2.0` at `fe95a9f`.
- `build/Verify.ps1` passed on that head: 1,098 Core, 1,568 Windows runtime and command line, 360 presentation tests, none failed; Core 100% of 2,246 lines; presentation 100% of 936 lines; coverage gate passed.
**Review (independent reviewer, three passes)**
- `6c9f67d`: two should-fix (a discovery address with no nameable host escaped as an unhandled exception; the provider's host was shown as the server wrote it in several log lines), nits. The reviewer probed about 70 hosts and found no input that made an exception cover a host other than the one the connection really goes to.
- `e28af87`: one new should-fix (a log line had stopped showing why a provider endpoint was refused), nits.
- `ca408e4`, the head merged: `Verdict on ca408e4: 0 blockers, 0 should-fix, 4 nits.` The reviewer checked rule by rule that the address check accepts exactly what it accepted before, and that the only audit row this change touches is C9.
**Left open, to be recorded in the audit (nits)**
- The typed origin is still compared as typed in `ServerTrust`; the `Origin` request header goes out as typed; a typed host with no ASCII name is accepted by the constructor and then fails every request as cross-origin.
- An IPv6 zone is kept for matching and dropped from display.
- The two provider sign-out refusals do not name the rule that failed.
- The second half of C9 (the discovery address is not bound to the issuer) is not part of this change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The development-only certificate exception was a boolean on the session
client. Once set, the client accepted a certificate Windows does not
trust from every host it contacted, the sign-in provider's discovery
address included, wherever the server said that was.
It is now QqqCertificateExceptionOrigins: an immutable set of at most two
well-formed HTTPS origins, the server's own and the sign-in provider it
names. The session client takes it in place of the boolean and accepts an
untrusted certificate only when the address of the request, or of the
socket upgrade, is on one of them. From any other host the certificate is
refused and the rejection recorded as before, and the log says which
origins the exception covers. AllowsUntrustedCertificate is gone;
CertificateException shows what the client was built with.
The compatibility result reports the origin of the provider the server
names whenever the sign-in description could be read, usable or not.
Callers. The service's session factory passes the server's origin, which
is what its stored setting means. The companion's flow passes the server's
origin, reads the sign-in description under it, and when the description
names a provider on another origin continues on a second session whose
exception also names the provider, with a warning naming both. Sign-in
against a separate provider with an untrusted certificate therefore still
works. In the service the provider is not covered until its origin is
recorded with the server, so the provider sign-out page cannot be
prepared for such a setup.
The type is not named QqqCertificateException because a type ending in
Exception that is not one is an analyzer error (CA1711), rightly.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The changelog says what changes for a development setup and what does
not. The threat model gains a section on the exception: the two origins,
where each comes from today, that the first half of audit finding C9 is
closed and the second is not, and what the later multi-server steps add.
Troubleshooting explains a sign-out failure against a separate provider
with an untrusted certificate.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Review of this branch found that the sign-in provider's origin kept the host
as the server wrote it. A zero-width space, U+2028 and look-alike letters
survived into what an operator is shown before a trust decision, and the
exception compared Host where the client's own same-origin rule compares
IdnHost, so a Unicode name and its punycode form did not match.
QqqOrigin.Of is now the one place an origin is formed: scheme, the host's
ASCII (IDNA) name and port. The provider origin of a sign-in description,
the origins of a certificate exception and the origin recorded with a
refused certificate are all built by it, and the exception compares the
ASCII host and the port. A look-alike name is shown as its xn-- form, a
zero-width character is not in the name, and a host with no ASCII name is
not an origin: Uri.IdnHost throws for a host that holds a line or paragraph
separator and leaves a name it does not treat as a domain name in Unicode,
and both are refused without an exception escaping. The log line for a
certificate decision names the host the same way, because the address can
be the provider's discovery address, which the server also wrote.
A session client talks to its server and to the sign-in provider that server
names, and kept one refusal without saying which of them presented the
certificate. QqqCertificateRejection now carries the origin of the address
the request or upgrade was going to, in the same ASCII form, and the time on
the client's injected clock. Both reach the compatibility result's
certificate, and the client's last refusal is readable as
LastCertificateRejection.
Tests pin: a Unicode host and its punycode form are one origin; the shown
form of a look-alike host is its xn-- form; a zero-width or separator
character cannot appear in a shown origin; trailing dot, IPv6 literal, short
IPv4 and default port; a refusal from the server names the server's origin,
one from the provider names the provider's, and the time is the clock's.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
CertificateException_AlsoCoversTheSeparateProviderItsServerNames gave both
sessions the same description, so it could not tell the first read from the
second. The second read now names a different provider: the exception is not
extended to it, no third session is made, and the warning is written once
and names the first provider.
New rows show what an operator reads: a provider whose name holds a
look-alike letter appears in the warning as its xn-- name, a zero-width
space is not in the name, nothing outside printable ASCII reaches the log,
and a provider whose host holds a line separator is no origin, so the
exception is not extended for it.
SignInFlow's comment says where the provider's origin comes from and in
which form; its code is unchanged.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The service logged "The server session could not be ended" for every failure
of a sign-out. With a sign-in provider on another origin and an untrusted
certificate, the server session was ended and only the provider step failed.
For a sign-out that asks for the provider's sign-out, the line now says that
one of the two steps failed and that the session entries before it say
which. A sign-out without a provider keeps its line. Nothing else in the
file changes.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The threat model describes the one form an origin is kept, compared and
shown in, what is not an origin, and what a refused certificate is kept
with. The changelog entry gains the same three points and the reworded
sign-out line. Audit finding C9 says its first half is closed by this change
and its second half, binding the provider's discovery address to the
issuer's origin, is open.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
PresentedCertificate.From took the address and the time as parameters, with
a note that the caller passes the address the rejection carries. Core's
QqqCertificateRejection now carries both itself, so the factory reads them
from it and the parameters are gone: a caller can no longer pass an address
it assumed. ServerCheckResult.From loses the two parameters it only handed
on. The origin is carried as before, scheme, host and port without a
trailing slash; a rejection with no origin leaves it absent.
The shape of the result is unchanged and so are the golden files. The
samples and tests build the rejection with its origin and time, and the
case of a certificate that was never presented now shows the time, which a
rejection always has.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
QqqSessionClient.SameOrigin read Uri.IdnHost on the raw addresses. The
platform throws UriFormatException for a host that holds a line or paragraph
separator, and the addresses compared can be text a server supplied: the
issuer it advertises, the provider's authorization endpoint, the end-session
endpoint it returns at sign-out and the one in the provider's discovery. A
server could therefore stop sign-in or sign-out with an exception that is
not a session failure.
SameOrigin now reduces both addresses to their origin with QqqOrigin.Of
first and compares those. An address with no nameable origin is not the same
origin as anything, so each of the four places is the ordinary refusal:
ConfigurationRequired, or InvalidResponse for the server's own sign-out
data. The test fails with UriFormatException without the change.
The threat model and the changelog say so.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A sign-in provider's discovery address with a host the platform cannot
name (one holding a line separator) passed the grant's checks, because
they asked only for a non-empty host. The compatibility check then
offered browser sign-in, and the sign-in stopped with the platform's
UriFormatException when the client tried to connect: provider discovery
is the one request that skips the same-origin rule, so nothing refused
the address first.
The check every server-supplied HTTPS address goes through now requires
the address to have an origin. An issuer or a discovery address without
one fails the grant, so sign-in, sign-out and a restored session report
a configuration problem before any request for the provider is made, and
the compatibility check reports the address and offers no browser
sign-in. The provider's endpoints went through the same check already
and were refused by the same-origin rule; they are now refused one step
earlier, with the same result.
Tests: the theory of places such an address can appear gains its fifth
row, and pins that the provider is never asked; the compatibility check
has rows for an issuer and a discovery address with no nameable host.
Five rows fail without the change.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The certificate lines and the exception warning named a sign-in
provider's host by the ASCII name the connection is made to, but the
lines around them still showed it as the server wrote it. With an issuer
holding a Cyrillic "o", the warning said xn--lgin-55d.example.com and
the next line said the sign-in page was being opened at a host that
reads as "login.example.com".
A host is now formed for display in one place, QqqOrigin.ShownAuthority:
the authority of the address's origin when it has one, otherwise the
authority as written, made printable. It is used by:
- QqqSessionDiagnostics.Describe(Uri), and so by the lines for the
provider's discovery document, authorization page and end-session
page, and by the route in the HTTP request and response lines, which
was logged without being made printable;
- the certificate decision lines, which had their own copy of the rule;
- the companion's two lines for the page it opens in the browser.
Three lines that quote an address the server supplied as text, where the
client says what it accepted or received (the grant's issuer and
discovery address at debug level, the accepted grant's issuer, and the
provider's issuer and authorization endpoint), describe it the same way
when it is an address with an origin. They therefore no longer carry a
query, fragment or user information. A line that refuses two texts for
not matching exactly still quotes both as written, made printable.
No line logs more of an address than before. A server whose own address
was typed with a name outside ASCII is now named by its ASCII name in the
lines that describe a whole address; the lines that name the server
itself are unchanged.
Tests: the scenario above across a browser sign-in and a provider
sign-out, at info and debug level, with every line required to be
printable ASCII; unit rows for the shown host and a described address;
one companion test for each of its two lines. Reverting any of the four
call sites fails a test.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
QqqCertificateExceptionOrigins says it is immutable, but Origins handed
out the array it keeps, typed as a read-only list. A caller could cast it
back and write through it, and so widen an exception after it was
accepted. Nothing in the product did.
The array is now kept behind a read-only wrapper. The new test fails
without it: the list is the array itself.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The status named the branch, which no longer exists once the pull request
is squash-merged. It now names the type that implements the exception and
the changelog entry that declares it.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The "Provider discovery:" line describes the provider's issuer and
authorization endpoint in the form every address is shown in, which
leaves out a query, a fragment and user information. An endpoint that
carries one of those is refused by the next line, so the log showed a
plain HTTPS address on the issuer's origin and then said it was not one.
With the address Azure AD B2C publishes, whose query selects the policy,
the query that caused the refusal appeared nowhere. Before the address
was described this way, that line was the only one that carried it.
Two changes, so the log alone says why:
- A described address says by kind what it left out: "(query omitted)",
"(user information omitted)", "(fragment omitted)", or several. The
content is still never logged, because it can be a secret.
- The refusal names the rule the endpoint broke: it carries a query, it
is not an HTTPS address, its host has no ASCII name, it is on another
origin, and so on. The rules are those the check always applied, now
written once as a list that both accepts an address and names the
first rule a refused one breaks.
An HTTP or WebSocket address whose host has no ASCII name is now
described the same way instead of being quoted whole, so its query is
not logged either.
Tests: a theory with the B2C address and one row for each rule; the two
existing rows for a refused endpoint now pin the rule. Four mutants (no
note, no rule, the old condition, two rules swapped) each fail a row.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Two lines could mislead for a server whose address was typed with a name
outside ASCII, once a described address names its host in ASCII:
- "Saved session refused: it belongs to X, not Y" compares the two
addresses as written, and showed both in ASCII form, so a session saved
under the "xn--" form read "it belongs to X, not X". Both are now shown
as written, without query, fragment or user information, made printable.
- "Refused a request to X: it is not on the session origin Y" compares
the ASCII names, and showed the origin as typed beside the address in
ASCII. The origin is now shown by its ASCII name too.
Nothing changes for a server typed in ASCII. One test holds both lines;
reverting either fails it.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The threat model said only a line that refuses two texts for not matching
exactly quotes them as written. The line that rejects a grant and the
compatibility problem shown to the operator also quote the issuer and
the discovery address as written, and so does the refusal of a browser
callback that names another issuer. The sentence now lists them, and
says what follows: a look-alike host can appear as written in a
rejection, never in a line that says what was accepted or used.
Documentation only.
Signed-off-by: James Maes <james@kof22.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Plan step 3 of the multi-server build, and the first half of audit finding C9: an accepted untrusted certificate is an exception for named origins, not for every host.
What changes
Behaviour to know about
Verification
ca408e4onrelease/2.0atfe95a9f.build/Verify.ps1passed on that head: 1,098 Core, 1,568 Windows runtime and command line, 360 presentation tests, none failed; Core 100% of 2,246 lines; presentation 100% of 936 lines; coverage gate passed.Review (independent reviewer, three passes)
6c9f67d: two should-fix (a discovery address with no nameable host escaped as an unhandled exception; the provider's host was shown as the server wrote it in several log lines), nits. The reviewer probed about 70 hosts and found no input that made an exception cover a host other than the one the connection really goes to.e28af87: one new should-fix (a log line had stopped showing why a provider endpoint was refused), nits.ca408e4, the head merged:Verdict on ca408e4: 0 blockers, 0 should-fix, 4 nits.The reviewer checked rule by rule that the address check accepts exactly what it accepted before, and that the only audit row this change touches is C9.Left open, to be recorded in the audit (nits)
ServerTrust; theOriginrequest header goes out as typed; a typed host with no ASCII name is accepted by the constructor and then fails every request as cross-origin.🤖 Generated with Claude Code