Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions enterprise/e2e/auth/hurl/directory.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,15 @@ HTTP 401
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="registry"

# The HTML rendering of an apiKey directory is gated just like the JSON API
# The HTML rendering of an apiKey directory is gated, rendering the 401 page
GET {{base}}/private/
Accept: text/html
HTTP 401
Content-Type: application/problem+json
Content-Type: text/html; charset=utf-8
WWW-Authenticate: Bearer realm="registry"
[Asserts]
xpath "string(//title)" == "Unauthorized"
xpath "string(//h2[@class='fw-bold'])" == "This page requires authentication"

GET {{base}}/private/
Accept: text/html
Expand Down
13 changes: 9 additions & 4 deletions enterprise/e2e/auth/hurl/schema-html.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ HTTP 200
[Asserts]
header "Content-Type" contains "text/html"

# apiKey schema HTML is denied without a credential
# apiKey schema HTML is denied without a credential, rendering the 401 page
GET {{base}}/private/secret
Accept: text/html
HTTP 401
Content-Type: application/problem+json
Content-Type: text/html; charset=utf-8
WWW-Authenticate: Bearer realm="registry"
[Asserts]
xpath "string(//title)" == "Unauthorized"
xpath "string(//h2[@class='fw-bold'])" == "This page requires authentication"

# apiKey schema HTML is served with the credential
GET {{base}}/private/secret
Expand All @@ -30,9 +33,11 @@ HTTP 200
[Asserts]
header "Content-Type" contains "text/html"

# A browser request (HTML preferred) for an apiKey schema is still denied
# A browser request (HTML preferred) for an apiKey schema renders the 401 page
GET {{base}}/private/secret
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
HTTP 401
Content-Type: application/problem+json
Content-Type: text/html; charset=utf-8
WWW-Authenticate: Bearer realm="registry"
[Asserts]
xpath "string(//title)" == "Unauthorized"
185 changes: 185 additions & 0 deletions enterprise/e2e/auth/hurl/unauthorized.all.hurl
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# An unauthorized caller gets an identical 401 for a protected path whether or
# not the schema exists, because the gate denies before the existence check.
# The JSON surface answers with an RFC 9457 problem document and the HTML
# surface with the index-time 401 page. Existence is only revealed to a caller
# the policy admits.

# JSON, existing protected schema, no credential: the full problem document,
# round-tripped through its own output schema
GET {{base}}/private/secret.json
HTTP 401
Cache-Control: no-store
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="registry"
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
denied_body: body
denied_schema: header "Link" regex "<([^>]+)>"
[Asserts]
header "ETag" not exists
header "Last-Modified" not exists
jsonpath "$.type" == "urn:sourcemeta:one:authentication-required"
jsonpath "$.title" == "Unauthorized"
jsonpath "$.status" == 401
jsonpath "$.detail" == "This resource requires authentication"

POST {{base}}/self/v1/api/schemas/evaluate{{denied_schema}}
```
{{denied_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

# JSON, nonexistent path under the same protected prefix, no credential: the
# byte-identical problem document, so probing cannot distinguish a real schema
# from a guess
GET {{base}}/private/ghost.json
HTTP 401
Cache-Control: no-store
Content-Type: application/problem+json
WWW-Authenticate: Bearer realm="registry"
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
ghost_body: body
ghost_schema: header "Link" regex "<([^>]+)>"
[Asserts]
jsonpath "$.type" == "urn:sourcemeta:one:authentication-required"
jsonpath "$.title" == "Unauthorized"
jsonpath "$.status" == 401
jsonpath "$.detail" == "This resource requires authentication"

POST {{base}}/self/v1/api/schemas/evaluate{{ghost_schema}}
```
{{ghost_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

# JSON, with the admitting credential, existing schema: the schema itself
GET {{base}}/private/secret.json
Authorization: Bearer primary-secret-key
HTTP 200
Cache-Control: public, max-age=0, must-revalidate
Content-Type: application/schema+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Link: <https://json-schema.org/draft/2020-12/schema>; rel="describedby"
[Asserts]
header "ETag" exists
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "{{base}}/private/secret",
"type": "object",
"required": [ "token" ],
"properties": {
"token": {
"type": "string"
}
},
"additionalProperties": false
}

# JSON, with the credential, nonexistent: existence is now revealed as a genuine
# 404 problem document, round-tripped through its output schema
GET {{base}}/private/ghost.json
Authorization: Bearer primary-secret-key
HTTP 404
Cache-Control: no-store
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
missing_body: body
missing_schema: header "Link" regex "<([^>]+)>"
[Asserts]
jsonpath "$.type" == "urn:sourcemeta:one:not-found"
jsonpath "$.title" == "Not Found"
jsonpath "$.status" == 404
jsonpath "$.detail" == "There is nothing at this URL"

POST {{base}}/self/v1/api/schemas/evaluate{{missing_schema}}
```
{{missing_body}}
```
HTTP 200
[Asserts]
jsonpath "$.valid" == true

# HTML, existing protected schema, no credential: the 401 page, carrying the
# same bearer challenge and no-store discipline as the JSON envelope
GET {{base}}/private/secret
Accept: text/html
HTTP 401
Cache-Control: no-store
Content-Type: text/html; charset=utf-8
WWW-Authenticate: Bearer realm="registry"
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
Vary: Accept, Accept-Encoding
[Asserts]
xpath "string(//title)" == "Unauthorized"
xpath "string(//h2[@class='fw-bold'])" == "This page requires authentication"
xpath "string(//p[@class='lead'])" == "Present a valid credential to access it"

# HTML, nonexistent path under the protected prefix, no credential: the
# byte-identical 401 page, so the HTML surface leaks no existence either
GET {{base}}/private/ghost
Accept: text/html
HTTP 401
Cache-Control: no-store
Content-Type: text/html; charset=utf-8
WWW-Authenticate: Bearer realm="registry"
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
Vary: Accept, Accept-Encoding
[Asserts]
xpath "string(//title)" == "Unauthorized"
xpath "string(//h2[@class='fw-bold'])" == "This page requires authentication"
xpath "string(//p[@class='lead'])" == "Present a valid credential to access it"

# HTML, a credential the policy does not admit: the byte-identical 401 page, so
# the response never reveals that a credential was presented at all
GET {{base}}/private/secret
Accept: text/html
Authorization: Bearer not-the-key
HTTP 401
Cache-Control: no-store
Content-Type: text/html; charset=utf-8
WWW-Authenticate: Bearer realm="registry"
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
Vary: Accept, Accept-Encoding
[Asserts]
xpath "string(//title)" == "Unauthorized"
xpath "string(//h2[@class='fw-bold'])" == "This page requires authentication"
xpath "string(//p[@class='lead'])" == "Present a valid credential to access it"

# HTML, with the admitting credential: the real schema page, never the 401 page
GET {{base}}/private/secret
Accept: text/html
Authorization: Bearer primary-secret-key
HTTP 200
Cache-Control: public, max-age=0, must-revalidate
[Asserts]
header "Content-Type" contains "text/html"
xpath "string(//title)" != "Unauthorized"

# HTML, the public carve-out under the protected prefix: anonymous, never the
# 401 page
GET {{base}}/private/open/shared
Accept: text/html
HTTP 200
Cache-Control: public, max-age=0, must-revalidate
[Asserts]
header "Content-Type" contains "text/html"
xpath "string(//title)" != "Unauthorized"
45 changes: 27 additions & 18 deletions src/actions/action_default_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ class ActionDefault_v1 : public sourcemeta::one::RouterAction {
credential, "", Tree::Explorer, "directory-html")};
if (root_html.outcome ==
sourcemeta::one::ArtifactResolution::Outcome::Denied) {
sourcemeta::one::json_error_unauthorized(request, response,
this->error_schema_, "*");
this->serve_unauthorized_html(HTML_BROWSER_SECURITY, request,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
response);
return;
}
if (root_html.outcome ==
Expand Down Expand Up @@ -148,8 +148,8 @@ class ActionDefault_v1 : public sourcemeta::one::RouterAction {
sourcemeta::one::ArtifactResolution::Outcome::Denied ||
directory_html.outcome ==
sourcemeta::one::ArtifactResolution::Outcome::Denied) {
sourcemeta::one::json_error_unauthorized(request, response,
this->error_schema_, "*");
this->serve_unauthorized_html(HTML_BROWSER_SECURITY, request,
response);
return;
}
if (!path.ends_with("/") &&
Expand All @@ -168,21 +168,11 @@ class ActionDefault_v1 : public sourcemeta::one::RouterAction {
this->error_schema_, "public, max-age=0, must-revalidate",
"Accept, Accept-Encoding");
} else {
const auto not_found{this->artifact_resolve_path(
credential, "", Tree::Explorer, "404")};
if (not_found.outcome ==
sourcemeta::one::ArtifactResolution::Outcome::Denied) {
sourcemeta::one::json_error_unauthorized(request, response,
this->error_schema_, "*");
return;
}
if (not_found.outcome ==
sourcemeta::one::ArtifactResolution::Outcome::Found) {
// The 404 HTML page is itself an error response, so it
// travels with the same `no-store` discipline as the
// JSON Problem Details errors.
const auto not_found{this->artifact_resolve_path_unauthenticated(
"", Tree::Explorer, "404")};
if (not_found.has_value()) {
this->artifact_serve(
not_found.path.value(), sourcemeta::core::HTTP_STATUS_NOT_FOUND,
not_found.value(), sourcemeta::core::HTTP_STATUS_NOT_FOUND,
false, {}, {}, HTML_BROWSER_SECURITY, request, response,
this->error_schema_, "no-store", "Accept, Accept-Encoding");
} else {
Expand Down Expand Up @@ -244,6 +234,25 @@ class ActionDefault_v1 : public sourcemeta::one::RouterAction {
}

private:
auto serve_unauthorized_html(
const sourcemeta::one::RouterAction::BrowserSecurityHeaders
&browser_security,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void {
const auto unauthorized{
this->artifact_resolve_path_unauthenticated("", Tree::Explorer, "401")};
if (unauthorized.has_value()) {
this->artifact_serve(
unauthorized.value(), sourcemeta::core::HTTP_STATUS_UNAUTHORIZED,
false, {}, {}, browser_security, request, response,
this->error_schema_, "no-store", "Accept, Accept-Encoding");
return;
}

sourcemeta::one::json_error_unauthorized(request, response,
this->error_schema_, "*");
}

std::string_view error_schema_;
};

Expand Down
3 changes: 2 additions & 1 deletion src/index/index.cc
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ using BuildHandlerFunction = auto (*)(
const sourcemeta::one::Configuration &, const sourcemeta::core::JSON &)
-> void;

static constexpr std::array<BuildHandlerFunction, 25> HANDLERS{{
static constexpr std::array<BuildHandlerFunction, 26> HANDLERS{{
&sourcemeta::one::GENERATE_MATERIALISED_SCHEMA::handler,
&sourcemeta::one::GENERATE_POINTER_POSITIONS::handler,
&sourcemeta::one::GENERATE_FRAME_LOCATIONS::handler,
Expand All @@ -88,6 +88,7 @@ static constexpr std::array<BuildHandlerFunction, 25> HANDLERS{{
&sourcemeta::one::GENERATE_VERSION::handler,
&sourcemeta::one::GENERATE_URITEMPLATE_ROUTES::handler,
&sourcemeta::one::GENERATE_AUTHENTICATION::handler,
&sourcemeta::one::GENERATE_WEB_UNAUTHORIZED::handler,
nullptr,
}};

Expand Down
14 changes: 13 additions & 1 deletion src/index/rules.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ enum : BuildPlan::Action::Type {
ACTION_VERSION,
ACTION_ROUTES,
ACTION_AUTHENTICATION,
ACTION_WEB_UNAUTHORIZED,
ACTION_REMOVE,
ACTION_COUNT
};

enum : BuildPlan::Type { MODE_HEADLESS, MODE_FULL };

inline constexpr DeltaRuleSet<13, 6, 5, 2> INDEX_RULES{
inline constexpr DeltaRuleSet<13, 7, 5, 2> INDEX_RULES{
.leaves = {{
{.action = ACTION_MATERIALISE,
.base = 0,
Expand Down Expand Up @@ -319,6 +320,17 @@ inline constexpr DeltaRuleSet<13, 6, 5, 2> INDEX_RULES{
.dependencies = {{{.kind = ContainerDependencyKind::ExternalConfig,
.filename = nullptr}}},
.dependency_count = 1},

{.action = ACTION_WEB_UNAUTHORIZED,
.base = 1,
.filename = "401.metapack",
.gate = TargetGate::OnlyInFullMode,
.scope = ContainerScope::RootOnly,
.only_full_rebuild = true,
.is_listing = false,
.dependencies = {{{.kind = ContainerDependencyKind::ExternalConfig,
.filename = nullptr}}},
.dependency_count = 1},
}},
.globals = {{
{.action = ACTION_VERSION,
Expand Down
8 changes: 8 additions & 0 deletions src/router/artifact.cc
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,14 @@ auto RouterAction::artifact_serve(

response.write_status(status);

// RFC 9110 §15.5.2: a 401 response MUST carry WWW-Authenticate. The
// conditional-request short-circuits above answer 304, never 401, so tying
// the challenge to the status keeps it correct by construction.
// https://datatracker.ietf.org/doc/html/rfc9110#section-15.5.2
if (status == sourcemeta::core::HTTP_STATUS_UNAUTHORIZED) {
response.write_header("WWW-Authenticate", "Bearer realm=\"registry\"");
}

// To support requests from web browsers
if (enable_cors) {
response.write_header("Access-Control-Allow-Origin", "*");
Expand Down
3 changes: 2 additions & 1 deletion src/web/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT one NAME web
pages/index.cc
pages/directory.cc
pages/schema.cc
pages/not_found.cc)
pages/not_found.cc
pages/unauthorized.cc)

target_link_libraries(sourcemeta_one_web PUBLIC sourcemeta::one::build)
target_link_libraries(sourcemeta_one_web PRIVATE sourcemeta::core::json)
Expand Down
Loading
Loading