Table of Contents

Framing and Content-Security-Policy

AddIdentityServerUi() puts a Content-Security-Policy on every page this library serves, and refuses to let any of them be rendered inside a frame:

default-src 'none'; img-src 'self' https: data:; style-src 'unsafe-inline';
base-uri 'none'; frame-ancestors 'none'

plus X-Frame-Options: DENY. It registers the Razor class library's application part and these headers; adding the application part by hand instead works, and leaves the pages framable. The code on this page is included from samples/Server.Hosting, which a test asks for its sign-in page and checks both headers.

The level

Nothing here is tunable per page; what is tunable is the level, CspOptions:

// CSP Level 2 (the default) carries frame-ancestors and hash sources. Level One is for browsers that predate
// it, and then X-Frame-Options is the only thing refusing to frame the pages: keep the deprecated headers on.
options.Csp.Level = CspLevel.Two;
options.Csp.AddDeprecatedHeaders = true;

Level decides two real things rather than a syntax flavour. frame-ancestors is CSP Level 2, so at CspLevel.One the framing refusal rests on X-Frame-Options alone — which is why turning AddDeprecatedHeaders off and dropping to Level One leaves the pages framable, and is the one combination to avoid. Hash sources are Level 2 as well, so the check-session iframe falls back to 'unsafe-inline' for its own script there.

In configuration, write the level by name ("Csp": { "Level": "One" }): its numbers are Duende's since 0.165.0 (One 0, Two 1; they were 1 and 2), so a number written for an earlier version now means the other level.

Why the pages refuse to be framed

The consent screen is why this exists. It grants an application access to a user's data on one click, and its precondition — a signed-in user — is the same precondition a framing attack needs. The attacker overlays the page invisibly on a decoy and the user clicks Allow believing they clicked something else. Antiforgery is not an answer to this: the browser submits the genuine form with the genuine token, because the user genuinely clicked the genuine button. A CSRF token proves a request came from this page, and in a clickjacking attack it did. The only thing that answers it is the browser refusing to render the page in a frame.

Two allowances in the policy are deliberate. img-src permits remote https: images because the consent screen renders Client.LogoUri, and a policy that forbade it would take every configured logo away with no error anywhere. style-src 'unsafe-inline' covers the layout's one inline stylesheet rather than hashing it — hashing turns an unrelated whitespace edit into an unstyled page, and a style sheet cannot execute. Script is not allowed at all: these pages run none.

No form-action, since 0.177.0. The policy said form-action 'self' until then, and browsers apply that directive to every redirect a form submission follows, not only to the form's own target: the HTML Standard runs CSP's pre-navigation check inside the navigation's redirect loop (create navigation params by fetching), with the navigation typed as a form submission throughout, and Chrome and Safari enforce it so. The sign-in and consent pages post to themselves and are answered with a redirect to the authorize endpoint, which answers with a redirect to the client — another origin — so the browser refused the last hop and the user never reached the application. The client's origin cannot be listed in advance: it is any registered redirect URI, a custom scheme for a native application, or an upstream provider's sign-out. Duende's templates send no form-action either. Nothing on these pages needs it: they run no script and render nothing a visitor wrote, so there is no injected form for it to stop.

The check-session iframe

/connect/checksession is the exception, and it is not an oversight. Relying parties embed that iframe on purpose (OpenID Connect Session Management 1.0 §4), so it carries no frame-ancestors and no X-Frame-Options — a blanket header would break session management in the way that is worst to diagnose: no error, just relying parties that quietly stop noticing sign-outs. It is still not unprotected. It gets default-src 'none'; script-src 'sha256-…', where the hash is computed from the string that is actually returned — cookie name substituted and all — so being embedded does not make it a place to execute something from. The hash is derived rather than written down beside the script, because a written-down hash stops matching the first time somebody edits the script, or configures CheckSessionCookieName, and the failure is a blank iframe that reports nothing.

The form_post response page has a policy of its own, allowing its one script by hash.

A host's own Razor pages are untouched. The filter is scoped to this library's page models, because a library quietly imposing default-src 'none' on a host's pages would break them somewhere far from here.

New in 0.30.0. Before it there was no Content-Security-Policy and no X-Frame-Options anywhere in the tree, so every interactive page — consent included — could be framed by anyone.