diff --git a/lib/inc/drogon/HttpRequest.h b/lib/inc/drogon/HttpRequest.h index 24d8c7579d..4433c1c84f 100644 --- a/lib/inc/drogon/HttpRequest.h +++ b/lib/inc/drogon/HttpRequest.h @@ -501,6 +501,37 @@ class DROGON_EXPORT HttpRequest return toRequest(std::forward(obj)); } + /*! \brief Check if the request is a CORS request. + * \details It should contain: + * - Origin: origination page + * \returns true if the Origin header is present + */ + inline bool isCorsRequest() const + { + // Check presence of required headers + return headers().find("origin") != headers().end(); + } + + /*! \brief Check if the request is a CORS pre-flight request. + * \details Check if the method of the request is OPTIONS and if it is + * a CORS pre-flight request.\n + * It should contain: + * - Origin: origination page + * - Access-Control-Request-Method: method to be used in the + * actual request + * \returns true if the method is OPTIONS and the required CORS pre-flight + * headers are present + */ + inline bool isCorsPreflightRequest() const + { + if (method() != HttpMethod::Options) + return false; + // Check presence of required headers + return isCorsRequest() && + headers().find("access-control-request-method") != + headers().end(); + } + virtual bool isOnSecureConnection() const noexcept = 0; virtual void setContentTypeString(const char *typeString, size_t typeStringLength) = 0; diff --git a/lib/inc/drogon/HttpResponse.h b/lib/inc/drogon/HttpResponse.h index 798055437f..69017dbfd5 100644 --- a/lib/inc/drogon/HttpResponse.h +++ b/lib/inc/drogon/HttpResponse.h @@ -552,6 +552,141 @@ class DROGON_EXPORT HttpResponse return toResponse(std::forward(obj)); } + /*! \brief Create an OPTIONS or CORS pre-flight response + * \details If the request is not an OPTIONS request, returns a NULL + * response\n + * If it is a generic OPTIONS request, returns a 204 No Content + * response with the Allow header\n + * If it is a CORS pre-flight request, returns a 204 No Content + * response with the CORS headers set + * + * Other status codes for CORS pre-flight answers: + * - 400 Bad Request: if the request is malformed (missing + * required headers) + * - 403 Forbidden: if the Origin is not allowed + reason + * in a X-Cors-Error header + * - 403 Forbidden: if one of the headers in + * Access-Control-Request-Headers is not allowed + reason in + * a X-Cors-Error header + * - 405 Method Not Allowed: if the requested method is + * not allowed + * \note CORS is a browser-side security mechanism.\n + * Do not rely on Origin for authentication/authorization: + * non-browser clients can spoof or omit it.\n + * Enforce access control independently. + * \param[in] request Drogon (OPTIONS) request + * \param[in] allowedHeaders Set of allowed headers (for + * Access-Control-Allow-Headers header)\n + * (headers allowed by the controller path + * handler) + * \param[in] originValidator Function to validate the Origin header value + * (allow the origin or not)\n + * If allowCredentials is true, originValidator + * _SHOULD_ enforce a strict allowlist + * \param[in] allowNullOrigin Should be true to accept the "Origin: null" + * header\n + * (set for local file:// pages, sandboxed + * iframes, opaque origins, data: URIs) + * \param[in] allowCredentials Should be true to add the header + * "Access-Control-Allow-Credentials: true" + * (controls whether the browser may include + * credentials such as cookies, HTTP auth, or + * client certificates)\n + * Note: Authorization (bearer) is not a + * credential header; allow it via + * allowedHeaders when needed + * \param[in] allowPNA Should be true to accept the header + * "Access-Control-Request-Private-Network" + * (when a page from a less private address + * space is trying to reach a more private + * one, like internet -> intranet)\n + * Note: specific to Chromium & derivatives + * (Edge, Opera, Brave, ...), not in Firefox + * or Safari + * \param[in] maxAgeSeconds If set, adds the "Access-Control-Max-Age" + * header with the given value (in seconds, + * how long the results of a preflight + * request can be cached by the navigator) + * \returns the OPTIONS or CORS pre-flight response, or a null pointer if + * the request is not an OPTIONS request + */ + static HttpResponsePtr newOptionsResponse( + const HttpRequestPtr &request, + const std::function &originValidator = nullptr, + bool allowNullOrigin = false, + bool allowCredentials = false, + bool allowPNA = true, + std::optional maxAgeSeconds = {}, + const std::optional> &allowedHeaders = + std::nullopt); + + /*! \copydoc newOptionsResponse(const HttpRequestPtr&, + * const std::function&, + * bool, bool, bool, + * std::optional, + * const std::optional>&) + * \remarks Helper when specifying the allowed headers, when other + * parameters may be default, to avoid having to specify them all + */ + inline static HttpResponsePtr newOptionsResponse( + const HttpRequestPtr &request, + const std::set &allowedHeaders, + const std::function &originValidator = nullptr, + bool allowNullOrigin = false, + bool allowCredentials = false, + bool allowPNA = true, + std::optional maxAgeSeconds = {}) + { + return newOptionsResponse(request, + originValidator, + allowNullOrigin, + allowCredentials, + allowPNA, + maxAgeSeconds, + allowedHeaders); + } + + /*! \brief Add CORS headers to a response + * \details Adds the CORS headers to a response for a normal request (a + * CORS request but not a CORS preflight request): + * - does nothing if it's an OPTIONS request, or + * - if it's not a CORS request, or + * - if it's a CORS preflight request + * Else: + * - adds Access-Control-Allow-Origin (if not yet present) + * - adds Origin to the Vary header, + * - sets or clears Access-Control-Allow-Credentials (if + * allowCredentials is set) + * - completes Access-Control-Expose-Headers + * \param[in] request Drogon request (to get Origin) + * \param[in] allowCredentials If set and true, adds the + * "Access-Control-Allow-Credentials: true + * header"\n + * If set and false, removes the + * "Access-Control-Allow-Credentials" header\n + * If not set, leaves the + * "Access-Control-Allow-Credentials" header + * untouched\n + * *MUST MATCH THE newOptionsResponse() + * PRE-FLIGHT RESPONSE VALUE* + * \param[in] exposedHeaders Set of exposed headers (for + * Access-Control-Expose-Headers header)\n + * These are the headers allowed to be exposed + * to javascript by the remote browser\n + * Note: they are *APPENDED* to any already + * present in the response, they are not + * REPLACED.\n + * This allows to complete them in the + * controller path handler.\n + * If you want to REPLACE them, remove the + * header before calling this function. + * \note may be use both in the controller path handler and in a + * pre-sending advice + */ + void addCorsHeaders(const HttpRequestPtr &request, + const std::set &exposedHeaders = {}, + const std::optional &allowCredentials = {}); + /** * @brief If the response is a file response (i.e. created by * newFileResponse) returns the path on the filesystem. Otherwise a diff --git a/lib/inc/drogon/utils/Utilities.h b/lib/inc/drogon/utils/Utilities.h index 23412500e5..370febed6d 100644 --- a/lib/inc/drogon/utils/Utilities.h +++ b/lib/inc/drogon/utils/Utilities.h @@ -124,6 +124,165 @@ DROGON_EXPORT std::set splitStringToSet( const std::string &str, const std::string &separator); +/*! \brief Compare two string_views for equality, ignoring case. + * \warning This is locale dependent + * \param[in] str1 The first string_view. + * \param[in] str2 The second string_view. + * \return true if the string_views are equal, ignoring case; false otherwise. + */ +inline bool ci_equals(std::string_view str1, std::string_view str2) +{ + if (str1.size() != str2.size()) + return false; + return std::equal(str1.begin(), + str1.end(), + str2.begin(), + [](unsigned char a, unsigned char b) { + return std::tolower(a) == std::tolower(b); + }); +} + +/*! \details Trim leading and trailing spaces and tabs from a string_view, + * modifying it. + * \param[in,out] str The string_view to trim. + * \return The trimmed string_view. + */ +inline std::string_view &trim_inplace(std::string_view &str) +{ + auto pos = str.find_first_not_of(" \t"); + // defeat Windows macro "min" + str.remove_prefix((std::min)(pos, str.size())); + if (str.empty()) + return str; + pos = str.find_last_not_of(" \t"); + str.remove_suffix(str.size() - pos - 1); + return str; +} + +/*! \brief Trim leading and trailing spaces and tabs from a string_view. + * \param[in] str The string_view to trim. + * \return A string_view with leading and trailing spaces and tabs removed. + */ +inline std::string_view trim(std::string_view str) +{ + return trim_inplace(str); +} + +/*! \brief Trim leading and trailing spaces and tabs from a rvalue string. + * \param[in] str The string to trim. + * \return The string with leading and trailing spaces and tabs removed. + */ +inline std::string trim(std::string &&str) +{ + auto pos = str.find_last_not_of(" \t"); + if (pos == std::string::npos) + return {}; + str.resize(pos + 1); + pos = str.find_first_not_of(" \t"); + if (pos > 0) + str.erase(0, pos); + return str; +} + +/*! \brief Split a string_view into a vector of string_views. + * \param[in] str The string_view to split. + * \param[in] separator The separator to use for splitting. + * \param[in] trimValues Whether to trim whitespace from the resulting + * string_views. + * \param[in] acceptEmptyString Whether to include empty strings in the result. + * \return A vector of string_views obtained by splitting the input + * string_view. + */ +inline std::vector splitStringView( + std::string_view str, + std::string_view separator, + bool trimValues = true, + bool acceptEmptyString = false) +{ + std::vector result; + if (separator.empty()) + { + if (trimValues) + trim_inplace(str); + if (acceptEmptyString || !str.empty()) + result.push_back(str); + return result; + } + size_t start = 0; + size_t end = 0; + while ((end = str.find(separator, start)) != std::string_view::npos) + { + auto token = str.substr(start, end - start); + if (trimValues) + trim_inplace(token); + if (acceptEmptyString || !token.empty()) + result.push_back(token); + start = end + separator.size(); + } + auto token = str.substr(start); + if (trimValues) + trim_inplace(token); + if (acceptEmptyString || !token.empty()) + { + result.push_back(token); + } + return result; +} + +/*! \brief Split a string_view into a set of string_views. + * \copyparams splitStringView + * \return A set of (unique) string_views obtained by splitting the input + * string_view. + * \note Uniqueness is case-sensitive: "A" and "a" are considered different + * values. + */ +inline std::set splitStringViewToSet( + std::string_view str, + std::string_view separator, + bool trimValues = true, + bool acceptEmptyString = false) +{ + auto v = splitStringView(str, separator, trimValues, acceptEmptyString); + return std::set(v.begin(), v.end()); +} + +/*! \brief Join a vector of string_view into a string. + * \param[in] strs The vector of string_views to join. + * \param[in] separator The separator to use between string_views. + * \return A single string obtained by joining the input string_views with the + * specified separator. + * \note Empty values are skipped. + */ +inline std::string joinStringViews(const std::vector &strs, + std::string_view separator) +{ + std::string result; + for (std::string_view str : strs) + { + if (trim_inplace(str).empty()) + continue; + if (!result.empty()) + result.append(separator); + result.append(str); + } + return result; +} + +/*! \brief Join a set of string_view into a string. + * \param[in] strs The set of string_views to join. + * \param[in] separator The separator to use between string_views. + * \return A single string obtained by joining the input string_views with the + * specified separator. + * \note Empty values are skipped. + */ +inline std::string joinStringViews(const std::set &strs, + std::string_view separator) +{ + return joinStringViews(std::vector{strs.begin(), + strs.end()}, + separator); +} + /// Get UUID string. DROGON_EXPORT std::string getUuid(bool lowercase = true); diff --git a/lib/src/HttpResponseImpl.cc b/lib/src/HttpResponseImpl.cc index 64b785c2c3..2aa980c7f1 100644 --- a/lib/src/HttpResponseImpl.cc +++ b/lib/src/HttpResponseImpl.cc @@ -473,6 +473,210 @@ HttpResponsePtr HttpResponse::newAsyncStreamResponse( return resp; } +HttpResponsePtr HttpResponse::newOptionsResponse( + const HttpRequestPtr &request, + const std::function &originValidator, + bool allowNullOrigin, + bool allowCredentials, + bool allowPNA, + std::optional maxAgeSeconds, + const std::optional> &allowedHeaders) +{ + if (!request || (request->method() != HttpMethod::Options)) + return {}; + // Allowed methods, set by drogon::HttpOptionsMiddlewareImpl + auto methods = + request->attributes()->get("drogon.corsMethods"); + if (methods.empty()) + methods = "OPTIONS"; + + auto response = newHttpResponse(HttpStatusCode::k204NoContent, + drogon::ContentType::CT_NONE); + // Disable HTTP caching for OPTIONS responses + response->addHeader("Cache-Control"s, "no-store"s); + // Vary on Origin for bad proxies that do not respect no-store or want + // Pragma: no-cache instead + response->addHeader("Vary"s, "Origin"); + // Generic OPTIONS response + if (!request->isCorsPreflightRequest()) + { + response->addHeader("Allow", methods); + return response; + } + + // CORS pre-flight response + std::string_view origin = drogon::utils::trim(request->getHeader("Origin")); + if (origin.empty()) + { + response->setStatusCode(HttpStatusCode::k400BadRequest); + response->addHeader("X-Cors-Error", + "invalid empty Origin"); // diagnose help + return response; + } + // Check whether null origin is allowed (file://, sandboxed iframes, etc.) + if (drogon::utils::ci_equals(origin, "null") && !allowNullOrigin) + { + response->setStatusCode(HttpStatusCode::k403Forbidden); + response->addHeader("X-Cors-Error", + "null Origin not allowed"); // diagnose help + return response; + } + // Check whether the origin is allowed + if (originValidator && !originValidator(origin)) + { + response->setStatusCode(HttpStatusCode::k403Forbidden); + response->addHeader("X-Cors-Error", + "origin not allowed"); // diagnose help + return response; + } + // Reflect the origin (acts like '*', that is forbidden when + // allowCredentials is true) + response->addHeader("Access-Control-Allow-Origin", std::string(origin)); + response->addHeader("Access-Control-Allow-Methods", methods); + // Check requested method + // Policy: explicitly fail preflight with 40x + diagnostic header rather + // than silently returning allowed methods + auto acrMethod = drogon::utils::trim( + request->getHeader("Access-Control-Request-Method")); + if (acrMethod.empty()) + { + response->setStatusCode(HttpStatusCode::k400BadRequest); + response->addHeader( + "X-Cors-Error", + "invalid empty Access-Control-Request-Method"); // diagnose help + return response; + } + const auto allowedMethods = drogon::utils::splitStringView(methods, ","); + if (std::find_if(allowedMethods.begin(), + allowedMethods.end(), + [&acrMethod](const std::string_view &method) { + return drogon::utils::ci_equals(method, acrMethod); + }) == allowedMethods.end()) + { + response->setStatusCode(HttpStatusCode::k405MethodNotAllowed); + response->addHeader("Allow", + methods); // failing CORS pre-flight with 405 must + // also return the Allow header + response->addHeader("X-Cors-Error", + "method not allowed: "s.append( + acrMethod)); // diagnose help + return response; + } + // Allowed headers (intersection with requested ones on success, all allowed + // on error) Note: Browsers typically include only non-safelisted headers in + // Access-Control-Request-Headers We validate strictly against + // allowedHeaders Policy: explicitly fail preflight with 403 + diagnostic + // header rather than silently omitting forbidden CORS headers + auto requestedHeaders = drogon::utils::splitStringViewToSet( + request->getHeader("Access-Control-Request-Headers"), ","); + if (allowedHeaders.has_value()) + { + auto &validHeaders = allowedHeaders.value(); + if (requestedHeaders.empty()) // noisy, but helpful for diagnosis + requestedHeaders = {validHeaders.begin(), validHeaders.end()}; + else + { + for (auto it = requestedHeaders.begin(); + it != requestedHeaders.end();) + { + auto &reqHeader = *it; + if (std::find_if(validHeaders.begin(), + validHeaders.end(), + [&reqHeader](const std::string_view &header) { + return drogon::utils::ci_equals( + reqHeader, + drogon::utils::trim(header)); + }) != validHeaders.end()) + { + ++it; + continue; + } + response->setStatusCode( + HttpStatusCode::k403Forbidden); // Forbidden header + response->addHeader("X-Cors-Error", + "disallowed header: "s.append( + reqHeader)); // diagnose help + // report all allowed headers to help diagnosing what's + // wrong + requestedHeaders = {validHeaders.begin(), validHeaders.end()}; + break; + } + } + } + if (!requestedHeaders.empty()) + response->addHeader("Access-Control-Allow-Headers", + drogon::utils::joinStringViews(requestedHeaders, + ",")); + if (response->statusCode() == HttpStatusCode::k403Forbidden) + return response; + // Allow credentials + if (allowCredentials) + response->addHeader("Access-Control-Allow-Credentials", "true"); + // Chromium-based browsers require this header to allow Private Network + // Access requests + if (allowPNA && + drogon::utils::ci_equals(request->getHeader( + "Access-Control-Request-Private-Network"), + "true")) + response->addHeader("Access-Control-Allow-Private-Network", "true"); + // Set a max age only on success + if (maxAgeSeconds.has_value()) + response->addHeader("Access-Control-Max-Age", + std::to_string(maxAgeSeconds.value())); + return response; +} + +void HttpResponse::addCorsHeaders( + const HttpRequestPtr &request, + const std::set &exposedHeaders, + const std::optional &allowCredentials) +{ + if (!request || !request->isCorsRequest() || + request->isCorsPreflightRequest()) + return; + // add/set Origin to the Vary header (needed for cache proxies) + auto vary = drogon::utils::splitStringViewToSet(getHeader("Vary"), ","); + if (std::find_if(vary.begin(), vary.end(), [](const auto &val) { + return drogon::utils::ci_equals(val, "Origin"); + }) == vary.end()) + { + vary.insert("Origin"); + addHeader("Vary", drogon::utils::joinStringViews(vary, ",")); + } + // add _MISSING_ CORS header - do not overwrite existing one + if (headers().find("access-control-allow-origin") == headers().end()) + addHeader("Access-Control-Allow-Origin", + std::string( + drogon::utils::trim(request->getHeader("Origin")))); + // set (or append) exposed headers + if (!exposedHeaders.empty()) + { + auto exposed = drogon::utils::splitStringViewToSet( + getHeader("Access-Control-Expose-Headers"), ","); + bool changed = false; + for (auto &header : exposedHeaders) + { + if (std::find_if(exposed.begin(), + exposed.end(), + [&header](const auto &val) { + return drogon::utils::ci_equals(val, header); + }) != exposed.end()) + continue; + exposed.insert(header); + changed = true; + } + if (changed) + addHeader("Access-Control-Expose-Headers", + drogon::utils::joinStringViews(exposed, ",")); + } + if (!allowCredentials.has_value()) + return; + if (allowCredentials.value()) + addHeader("Access-Control-Allow-Credentials", "true"); + else + removeHeader("Access-Control-Allow-Credentials"); +} + void HttpResponseImpl::makeHeaderString(trantor::MsgBuffer &buffer) { buffer.ensureWritableBytes(128); diff --git a/lib/tests/unittests/HttpHeaderTest.cc b/lib/tests/unittests/HttpHeaderTest.cc index 4a46bb193a..67d75677f4 100644 --- a/lib/tests/unittests/HttpHeaderTest.cc +++ b/lib/tests/unittests/HttpHeaderTest.cc @@ -66,3 +66,176 @@ DROGON_TEST(ResquestSetCustomContentTypeString) req->setContentTypeString("thisdoesnotexist/unknown"); CHECK(req->getContentType() == CT_CUSTOM); } + +DROGON_TEST(HttpOptionsHeadersResponse) +{ + auto req = HttpRequest::newHttpRequest(); + auto resp = HttpResponse::newOptionsResponse(req); + CHECK(!resp); + + req->setMethod(HttpMethod::Options); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Vary") == "Origin"); + CHECK(resp->getHeader("Allow") == "OPTIONS"); + CHECK(resp->getHeader("Access-Control-Allow-Origin") == ""); + CHECK(resp->getHeader("Access-Control-Allow-Methods") == ""); + + req->attributes()->insert("drogon.corsMethods", + std::string("GET, POST, OPTIONS")); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Vary") == "Origin"); + CHECK(resp->getHeader("Allow") == "GET, POST, OPTIONS"); + CHECK(resp->getHeader("Access-Control-Allow-Origin") == ""); + CHECK(resp->getHeader("Access-Control-Allow-Methods") == ""); + + req->addHeader("Origin", "http://somepage"); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Vary") == "Origin"); + CHECK(resp->getHeader("Allow") == "GET, POST, OPTIONS"); + CHECK(resp->getHeader("Access-Control-Allow-Origin") == ""); + CHECK(resp->getHeader("Access-Control-Allow-Methods") == ""); +} + +DROGON_TEST(HttpCorsHeadersResponse) +{ + auto req = HttpRequest::newHttpRequest(); + req->addHeader("Origin", ""); + req->addHeader("Access-Control-Request-Method", "OPTIONS"); + auto resp = HttpResponse::newOptionsResponse(req); + CHECK(!resp); + + // empty origin -> error + req->setMethod(HttpMethod::Options); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k400BadRequest); + + // null origin -> check if allowed or not + req->addHeader("Origin", "null"); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k403Forbidden); + resp = HttpResponse::newOptionsResponse(req, {}, true); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Access-Control-Allow-Origin") == "null"); + + // normal origin but no requested method -> error + req->addHeader("Origin", "http://somepage"); + req->addHeader("Access-Control-Request-Method", ""); + resp = HttpResponse::newOptionsResponse(req, {}, true); + CHECK(resp->getStatusCode() == HttpStatusCode::k400BadRequest); + + // valid CORS preflight request + req->addHeader("Access-Control-Request-Method", "OPTIONS"); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Vary") == "Origin"); + CHECK(resp->getHeader("Allow") == ""); + CHECK(resp->getHeader("Access-Control-Allow-Origin") == "http://somepage"); + CHECK(resp->getHeader("Access-Control-Allow-Methods") == "OPTIONS"); + + // origin validator + resp = HttpResponse::newOptionsResponse(req, [](std::string_view origin) { + return origin == "http://somepage"; + }); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + resp = HttpResponse::newOptionsResponse(req, [](std::string_view origin) { + return origin != "http://somepage"; + }); + CHECK(resp->getStatusCode() == HttpStatusCode::k403Forbidden); + + // unallowed method + req->addHeader("Access-Control-Request-Method", "PUT"); + req->attributes()->insert("drogon.corsMethods", + std::string("GET,POST,OPTIONS")); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k405MethodNotAllowed); + CHECK(resp->getHeader("Allow") == "GET,POST,OPTIONS"); + CHECK(resp->getHeader("Access-Control-Allow-Methods") == + "GET,POST,OPTIONS"); + + // allowed method + req->addHeader("Access-Control-Request-Method", "GET"); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Allow") == ""); + CHECK(resp->getHeader("Access-Control-Allow-Origin") == "http://somepage"); + CHECK(resp->getHeader("Access-Control-Allow-Methods") == + "GET,POST,OPTIONS"); + CHECK(resp->getHeader("Access-Control-Allow-Credentials") == ""); + CHECK(resp->getHeader("Access-Control-Allow-Private-Network") == ""); + CHECK(resp->getHeader("Access-Control-Max-Age") == ""); + + // no restriction on requested headers + req->addHeader("Access-Control-Request-Headers", "X-Foo, X-Bar"); + resp = HttpResponse::newOptionsResponse(req); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Access-Control-Allow-Headers") == "X-Bar,X-Foo"); + + // unallowed header + resp = HttpResponse::newOptionsResponse(req, {"X-Foo"}); + CHECK(resp->getStatusCode() == HttpStatusCode::k403Forbidden); + CHECK(resp->getHeader("Access-Control-Allow-Headers") == "X-Foo"); + + // all requested headers allowed + resp = HttpResponse::newOptionsResponse(req, {"X-Foo", "X-Bar"}); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Access-Control-Allow-Headers") == "X-Bar,X-Foo"); + + // allow credentials + resp = HttpResponse::newOptionsResponse(req, nullptr, false, true); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Access-Control-Allow-Credentials") == "true"); + + // private network access + req->addHeader("Access-Control-Request-Private-Network", "true"); + resp = HttpResponse::newOptionsResponse(req, nullptr, false, false, false); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Access-Control-Allow-Private-Network") == ""); + resp = HttpResponse::newOptionsResponse(req, nullptr, false, false, true); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Access-Control-Allow-Private-Network") == "true"); + + // CORS max age + resp = HttpResponse::newOptionsResponse( + req, nullptr, false, false, false, 600); + CHECK(resp->getStatusCode() == HttpStatusCode::k204NoContent); + CHECK(resp->getHeader("Access-Control-Max-Age") == "600"); +} + +DROGON_TEST(AddHttpCorsHeaders) +{ + using namespace std::literals; + + // no Origin -> do nothing + auto req = HttpRequest::newHttpRequest(); + req->setMethod(Get); + auto resp = HttpResponse::newHttpResponse(); + resp->addCorsHeaders(req, {"X-Foo"}, true); + CHECK(resp->headers().empty()); + + // with Origin -> Allow-Origin + Vary (not overwritten) + Expose-Headers + req->addHeader("Origin", "http://somepage"); + resp->addHeader("Vary", "X-SomeHeader"); + resp->addCorsHeaders(req, {"X-Foo"}); + CHECK(resp->getHeader("Vary") == "Origin,X-SomeHeader"); + CHECK(resp->getHeader("Access-Control-Allow-Origin") == "http://somepage"); + CHECK(resp->getHeader("Access-Control-Expose-Headers") == "X-Foo"); + + // add a new exposed header + resp->addCorsHeaders(req, {"X-Bar"}); + CHECK(resp->getHeader("Access-Control-Expose-Headers") == "X-Bar,X-Foo"); + // no duplicate Origin in Vary + CHECK(resp->getHeader("Vary") == "Origin,X-SomeHeader"); + + // check credentials (true/false/unchanged) + resp->addCorsHeaders(req, {}, true); + CHECK(resp->getHeader("Access-Control-Expose-Headers") == "X-Bar,X-Foo"); + CHECK(resp->getHeader("Access-Control-Allow-Credentials") == "true"); + resp->addCorsHeaders(req, {}, false); + CHECK(resp->getHeader("Access-Control-Allow-Credentials") == ""); + resp->addCorsHeaders(req, {}, true); + resp->addCorsHeaders(req); + CHECK(resp->getHeader("Access-Control-Allow-Credentials") == "true"); +}