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
8 changes: 7 additions & 1 deletion enterprise/e2e/path/hurl/mcp-2025-11-25-lifecycle.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,14 @@ header "X-Frame-Options" not exists
header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/

OPTIONS {{base}}/self/v1/mcp
HTTP 404
HTTP 204
Cache-Control: no-store
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
Access-Control-Allow-Headers: Accept, Accept-Encoding, If-None-Match, If-Modified-Since
Access-Control-Max-Age: 3600
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand Down
10 changes: 8 additions & 2 deletions src/actions/action_default_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class ActionDefault_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view>,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept, Accept-Encoding, If-None-Match, "
"If-Modified-Since");
return;
}
// Browser-targeted security headers we apply to every HTML response:
//
// - Referrer-Policy (W3C Referrer Policy):
Expand Down Expand Up @@ -91,7 +97,7 @@ class ActionDefault_v1 : public sourcemeta::one::RouterAction {
request, response, sourcemeta::core::HTTP_STATUS_METHOD_NOT_ALLOWED,
"sourcemeta:one/method-not-allowed",
"This HTTP method is invalid for this URL", this->error_schema_,
"*", "GET, HEAD");
"*", "GET, HEAD, OPTIONS");
return;
}
if (sourcemeta::core::http_match_accept(
Expand Down Expand Up @@ -180,7 +186,7 @@ class ActionDefault_v1 : public sourcemeta::one::RouterAction {
request, response, sourcemeta::core::HTTP_STATUS_METHOD_NOT_ALLOWED,
"sourcemeta:one/method-not-allowed",
"This HTTP method is invalid for this URL", this->error_schema_,
"*", "GET, HEAD");
"*", "GET, HEAD, OPTIONS");
} else {
sourcemeta::one::json_error(
request, response, sourcemeta::core::HTTP_STATUS_NOT_FOUND,
Expand Down
6 changes: 6 additions & 0 deletions src/actions/action_dependency_tree_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ class ActionDependencyTree_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view> matches,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept-Encoding, If-None-Match, "
"If-Modified-Since");
return;
}
if (matches.empty()) {
sourcemeta::one::json_error(
request, response,
Expand Down
7 changes: 6 additions & 1 deletion src/actions/action_health_check_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,17 @@ class ActionHealthCheck_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view>,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept, Accept-Encoding");
return;
}
if (request.method() != "get" && request.method() != "head") {
sourcemeta::one::json_error(
request, response, sourcemeta::core::HTTP_STATUS_METHOD_NOT_ALLOWED,
"sourcemeta:one/method-not-allowed",
"This HTTP method is invalid for this URL", this->error_schema_, "*",
"GET, HEAD");
"GET, HEAD, OPTIONS");
return;
}

Expand Down
6 changes: 6 additions & 0 deletions src/actions/action_jsonschema_serve_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ class ActionJSONSchemaServe_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view> matches,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept, Accept-Encoding, If-None-Match, "
"If-Modified-Since");
return;
}
serve(*this, matches.front(), request, response, this->error_schema_);
}

Expand Down
6 changes: 6 additions & 0 deletions src/actions/action_list_directory_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ class ActionListDirectory_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view> matches,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept-Encoding, If-None-Match, "
"If-Modified-Since");
return;
}
const std::string_view path_match{matches.empty() ? std::string_view{}
: matches.front()};
const auto path{
Expand Down
7 changes: 6 additions & 1 deletion src/actions/action_schema_search_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class ActionSchemaSearch_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view>,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept, Accept-Encoding");
return;
}

// RFC 9110 §9.3.2: "The HEAD method is identical to GET except that the
// server MUST NOT send content in the response. [...] The server SHOULD
Expand All @@ -67,7 +72,7 @@ class ActionSchemaSearch_v1 : public sourcemeta::one::RouterAction {
request, response, sourcemeta::core::HTTP_STATUS_METHOD_NOT_ALLOWED,
"sourcemeta:one/method-not-allowed",
"This HTTP method is invalid for this URL", this->error_schema_, "*",
"GET, HEAD");
"GET, HEAD, OPTIONS");
return;
}

Expand Down
6 changes: 6 additions & 0 deletions src/actions/action_serve_explorer_artifact_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ class ActionServeExplorerArtifact_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view> matches,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept-Encoding, If-None-Match, "
"If-Modified-Since");
return;
}
if (!matches.empty() &&
(matches.front().find('#') != std::string_view::npos ||
matches.front().find("%23") != std::string_view::npos)) {
Expand Down
6 changes: 6 additions & 0 deletions src/actions/action_serve_schema_artifact_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ class ActionServeSchemaArtifact_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view> matches,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept-Encoding, If-None-Match, "
"If-Modified-Since");
return;
}
if (matches.empty()) {
sourcemeta::one::json_error(
request, response,
Expand Down
8 changes: 7 additions & 1 deletion src/actions/action_serve_static_v1.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,19 @@ class ActionServeStatic_v1 : public sourcemeta::one::RouterAction {
auto rest(const std::span<std::string_view> matches,
sourcemeta::one::HTTPRequest &request,
sourcemeta::one::HTTPResponse &response) -> void override {
if (request.method() == "options") {
sourcemeta::one::cors_preflight(request, response, "GET, HEAD, OPTIONS",
"Accept-Encoding, If-None-Match, "
"If-Modified-Since");
return;
}
if (this->file_root_.empty()) {
if (request.method() != "get" && request.method() != "head") {
sourcemeta::one::json_error(
request, response, sourcemeta::core::HTTP_STATUS_METHOD_NOT_ALLOWED,
"sourcemeta:one/method-not-allowed",
"This HTTP method is invalid for this URL", this->error_schema_,
"*", "GET, HEAD");
"*", "GET, HEAD, OPTIONS");
return;
}

Expand Down
31 changes: 31 additions & 0 deletions src/http/include/sourcemeta/one/http_helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,37 @@ inline auto send_response(const sourcemeta::core::HTTPStatus &status,
std::format("{} {} {}", status.wire, request.method(), request.path()));
}

// RFC 9110 §9.3.7: OPTIONS responses describe communication options
// for the target resource. Fetch §3.2.2 (CORS preflight): non-simple
// cross-origin requests issue an OPTIONS preflight whose ACK shape
// (status 204 + Access-Control-Allow-*) governs whether the actual
// request fires. The per-surface `allow_methods` and `allow_headers`
// are required so each action declares its own contract explicitly,
// matching the K and L disciplines.
// https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.7
// https://fetch.spec.whatwg.org/#cors-preflight-fetch
inline auto cors_preflight(const HTTPRequest &request, HTTPResponse &response,
const std::string_view allow_methods,
const std::string_view allow_headers) -> void {
assert(!allow_methods.empty());
assert(!allow_headers.empty());
assert(allow_methods.find_first_of("\r\n") == std::string_view::npos);
assert(allow_headers.find_first_of("\r\n") == std::string_view::npos);
response.write_status(sourcemeta::core::HTTP_STATUS_NO_CONTENT);
response.write_header("Access-Control-Allow-Origin", "*");
response.write_header("Access-Control-Expose-Headers", "Link, ETag");
response.write_header("Access-Control-Allow-Methods", allow_methods);
response.write_header("Access-Control-Allow-Headers", allow_headers);
response.write_header("Access-Control-Max-Age", "3600");
// Browser preflight cache is governed by `Access-Control-Max-Age`;
// `no-store` keeps shared HTTP caches from storing this response.
response.write_header("Cache-Control", "no-store");
// RFC 9110 §9.3.7: OPTIONS responses SHOULD include Allow. Different
// audience than Access-Control-Allow-Methods (HTTP vs CORS preflight).
response.write_header("Allow", allow_methods);
send_response(sourcemeta::core::HTTP_STATUS_NO_CONTENT, request, response);
}

// CORS scope is required at every error site. No default for `origin` so a
// caller cannot silently widen a restricted-origin handler to wildcard. An
// empty origin means the route is CORS-disabled and no Allow-Origin or
Expand Down
9 changes: 8 additions & 1 deletion src/router/artifact.cc
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,19 @@ auto RouterAction::artifact_serve(
// the default action is Accept-branched, others just gzip), so
// the caller has to pick the right stack.
assert(!vary.empty());
// OPTIONS preflight is a per-surface concern (the Allow-Headers axis
// varies by action), so each caller short-circuits it before reaching
// here via `cors_preflight`. If this assertion fires, a caller is
// missing that early branch and the 405 emitted below would advertise
// `Allow: GET, HEAD, OPTIONS` while simultaneously refusing OPTIONS,
// which is internally inconsistent.
assert(request.method() != "options");
if (request.method() != "get" && request.method() != "head") {
sourcemeta::one::json_error(
request, response, sourcemeta::core::HTTP_STATUS_METHOD_NOT_ALLOWED,
"sourcemeta:one/method-not-allowed",
"This HTTP method is invalid for this URL", error_schema,
enable_cors ? "*" : "", "GET, HEAD");
enable_cors ? "*" : "", "GET, HEAD, OPTIONS");

@augmentcode augmentcode Bot Jun 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RouterAction::artifact_serve still treats options as method-not-allowed (if method != get && != head), but the 405 response now advertises Allow: GET, HEAD, OPTIONS. If an OPTIONS request ever reaches this helper (e.g., a caller forgets the early preflight branch), the response would be internally inconsistent (405 while claiming OPTIONS is allowed).

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
return;
}

Expand Down
2 changes: 1 addition & 1 deletion test/e2e/headless/hurl/explorer.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
last_response: body
Expand Down
6 changes: 3 additions & 3 deletions test/e2e/headless/hurl/fetch.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand All @@ -262,7 +262,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand Down Expand Up @@ -302,7 +302,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
Link: </self/v1/schemas/api/error>; rel="describedby"
[Captures]
last_response_405: body
Expand Down
19 changes: 9 additions & 10 deletions test/e2e/headless/hurl/health.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand All @@ -48,7 +48,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand All @@ -65,7 +65,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand All @@ -82,7 +82,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand All @@ -94,18 +94,17 @@ jsonpath "$.type" == "sourcemeta:one/method-not-allowed"
jsonpath "$.title" == "Method Not Allowed"

OPTIONS {{base}}/self/v1/health
HTTP 405
HTTP 204
Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
Access-Control-Allow-Headers: Accept, Accept-Encoding
Access-Control-Max-Age: 3600
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
header "Content-Security-Policy" not exists
header "X-Frame-Options" not exists
header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
jsonpath "$.status" == 405
jsonpath "$.type" == "sourcemeta:one/method-not-allowed"
jsonpath "$.title" == "Method Not Allowed"
6 changes: 3 additions & 3 deletions test/e2e/headless/hurl/no-static.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand All @@ -51,7 +51,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand All @@ -68,7 +68,7 @@ Cache-Control: no-store
Content-Type: application/problem+json
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Link, ETag
Allow: GET, HEAD
Allow: GET, HEAD, OPTIONS
[Asserts]
header "Vary" not exists
header "Referrer-Policy" not exists
Expand Down
1 change: 0 additions & 1 deletion test/e2e/headless/hurl/not-found.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,3 @@ header "Content-Security-Policy" not exists
header "X-Frame-Options" not exists
header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/
jsonpath "$.valid" == true

Loading
Loading