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
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ if(BEEPBOX_BUILD_SERVER)
src/RateLimiter.cpp
src/Metrics.cpp
src/Tracing.cpp
src/CorsConfig.cpp
src/CorsAdvice.cpp
)

target_include_directories(beepbox-server PRIVATE
Expand Down Expand Up @@ -224,10 +226,12 @@ if(BUILD_TESTING)
tests/RateLimiterTests.cpp
tests/MetricsTests.cpp
tests/TracingTests.cpp
tests/CorsConfigTests.cpp
src/ApiKeyAuth.cpp
src/RateLimiter.cpp
src/Metrics.cpp
src/Tracing.cpp
src/CorsConfig.cpp
)

target_link_libraries(BeepBoxTests PRIVATE beepbox_lib Catch2::Catch2WithMain)
Expand Down
89 changes: 89 additions & 0 deletions include/beepbox/CorsConfig.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Beeping contributors

#pragma once

#include <optional>
#include <string>
#include <string_view>
#include <vector>

namespace beepbox {

/**
* Pure logic that decides which CORS Allow-Origin to echo back.
*
* Backed by an exact-match whitelist parsed from a CSV env var
* (BEEPBOX_CORS_ALLOWED_ORIGINS). No wildcards, no regex, no scheme
* coercion — if the request `Origin` header doesn't match a configured
* entry verbatim, no CORS headers are emitted and the browser blocks
* the response naturally.
*
* When the whitelist is empty (env var unset or empty), CORS is
* effectively OFF — which is the backwards-compatible default for
* the existing server-to-server flows that don't need CORS.
*
* Drogon integration lives in CorsConfig.cpp behind installCorsHandlers().
* The pure parsing and resolution logic is exposed here so it can be
* unit-tested without spinning up the HTTP server.
*/
class CorsConfig {
public:
/// Parse a CSV (comma-separated origins). Trims surrounding whitespace
/// per entry and drops empty entries. Order of origins in the input is
/// preserved.
static std::vector<std::string> parseAllowedOrigins(std::string_view csv);

CorsConfig() = default;
explicit CorsConfig(std::vector<std::string> allowedOrigins);

/// True if at least one origin is whitelisted. When false, callers
/// should skip installCorsHandlers() entirely so Drogon's request
/// pipeline is unchanged for the legacy server-to-server flow.
bool enabled() const noexcept { return !allowedOrigins_.empty(); }

const std::vector<std::string>& allowedOrigins() const noexcept {
return allowedOrigins_;
}

/// Returns the value to echo back as `Access-Control-Allow-Origin`
/// for a given request `Origin` header, or std::nullopt if the
/// origin is not whitelisted (or empty/missing).
std::optional<std::string> resolveAllowOrigin(
std::string_view requestOrigin) const;

/// Static headers — same on every preflight response.
static constexpr std::string_view kAllowMethods =
"GET, POST, OPTIONS";
static constexpr std::string_view kAllowHeaders =
"Authorization, Content-Type, traceparent";
static constexpr std::string_view kMaxAge = "600";

private:
std::vector<std::string> allowedOrigins_;
};

/**
* Hook the CORS config into Drogon's request pipeline.
*
* Registers two pieces of advice (the two integration points Drogon
* exposes for cross-cutting concerns):
*
* 1. preRoutingAdvice → intercepts OPTIONS preflight requests before
* any handler or auth filter sees them. If the request `Origin`
* is whitelisted, responds 204 with the four `Access-Control-*`
* headers; otherwise responds 204 with no CORS headers and the
* browser refuses the actual request.
*
* 2. postHandlingAdvice → appends `Access-Control-Allow-Origin` (and
* `Vary: Origin`) to every non-OPTIONS response when the request
* came from a whitelisted origin. Skipped for any other origin so
* server-to-server callers (no Origin header) see no behavioural
* change.
*
* Calling this when `cfg.enabled() == false` is a no-op so it's safe
* to invoke unconditionally at startup.
*/
void installCorsHandlers(CorsConfig cfg);

} // namespace beepbox
8 changes: 8 additions & 0 deletions infra/cloud-run.tf
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ resource "google_cloud_run_v2_service" "beepbox" {
value = "8"
}

# CORS allowed origins (CSV). Empty/unset disables CORS — kept
# disabled for prod until BEE-1794 lands a separate prod deploy
# so the dev rollout doesn't affect server-to-server callers.
env {
name = "BEEPBOX_CORS_ALLOWED_ORIGINS"
value = var.cors_allowed_origins
}

startup_probe {
http_get {
path = "/healthz"
Expand Down
1 change: 1 addition & 0 deletions infra/terraform.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
project_id = "beeping-platform-dev"
region = "europe-west1"
image_tag = "latest"
cors_allowed_origins = "http://localhost:3000,https://beeping-platform-dev.web.app,https://beeping-platform-dev.firebaseapp.com"
11 changes: 11 additions & 0 deletions infra/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,14 @@ variable "image_tag" {
type = string
default = "latest"
}

variable "cors_allowed_origins" {
description = <<-EOT
Comma-separated list of CORS-whitelisted origins for browser
callers (BEE-1794). Empty disables CORS — server-to-server flows
keep working unchanged. For dev, expects:
`http://localhost:3000,https://beeping-platform-dev.web.app,https://beeping-platform-dev.firebaseapp.com`.
EOT
type = string
default = ""
}
57 changes: 57 additions & 0 deletions src/CorsAdvice.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Beeping contributors

#include "beepbox/CorsConfig.h"

#include <drogon/drogon.h>

namespace beepbox {

void installCorsHandlers(CorsConfig cfg) {
if (!cfg.enabled()) return;

// PreRoutingAdvice: catch OPTIONS preflights before auth/rate-limit
// filters. The browser sends OPTIONS without the Bearer token, so if
// we let it fall through to /v1/encode the auth handler would 401 it
// and the actual CORS preflight would never succeed.
drogon::app().registerPreRoutingAdvice(
[cfg](const drogon::HttpRequestPtr& req,
drogon::AdviceCallback&& callback,
drogon::AdviceChainCallback&& chain) {
if (req->method() != drogon::Options) {
chain();
return;
}
auto resp = drogon::HttpResponse::newHttpResponse();
resp->setStatusCode(drogon::k204NoContent);
const auto origin = req->getHeader("Origin");
if (auto allowed = cfg.resolveAllowOrigin(origin); allowed) {
resp->addHeader("Access-Control-Allow-Origin", *allowed);
resp->addHeader("Access-Control-Allow-Methods",
std::string(CorsConfig::kAllowMethods));
resp->addHeader("Access-Control-Allow-Headers",
std::string(CorsConfig::kAllowHeaders));
resp->addHeader("Access-Control-Max-Age",
std::string(CorsConfig::kMaxAge));
resp->addHeader("Vary", "Origin");
}
callback(resp);
});

// PostHandlingAdvice: stamp Allow-Origin on every non-OPTIONS response
// (POST /v1/encode, GET /healthz, …) when the request originated from
// a whitelisted browser. No-op for server-to-server callers because
// they don't send an `Origin` header.
drogon::app().registerPostHandlingAdvice(
[cfg](const drogon::HttpRequestPtr& req,
const drogon::HttpResponsePtr& resp) {
if (req->method() == drogon::Options) return;
const auto origin = req->getHeader("Origin");
if (auto allowed = cfg.resolveAllowOrigin(origin); allowed) {
resp->addHeader("Access-Control-Allow-Origin", *allowed);
resp->addHeader("Vary", "Origin");
}
});
}

} // namespace beepbox
59 changes: 59 additions & 0 deletions src/CorsConfig.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Beeping contributors

#include "beepbox/CorsConfig.h"

#include <cctype>
#include <utility>

namespace beepbox {

namespace {

std::string_view trim(std::string_view sv) {
const auto isSpace = [](unsigned char c) { return std::isspace(c) != 0; };
while (!sv.empty() && isSpace(static_cast<unsigned char>(sv.front()))) {
sv.remove_prefix(1);
}
while (!sv.empty() && isSpace(static_cast<unsigned char>(sv.back()))) {
sv.remove_suffix(1);
}
return sv;
}

} // namespace

std::vector<std::string> CorsConfig::parseAllowedOrigins(
std::string_view csv) {
std::vector<std::string> out;
std::size_t start = 0;
while (start <= csv.size()) {
std::size_t comma = csv.find(',', start);
std::string_view chunk = csv.substr(
start, comma == std::string_view::npos ? csv.size() - start
: comma - start);
auto trimmed = trim(chunk);
if (!trimmed.empty()) {
out.emplace_back(trimmed);
}
if (comma == std::string_view::npos) break;
start = comma + 1;
}
return out;
}

CorsConfig::CorsConfig(std::vector<std::string> allowedOrigins)
: allowedOrigins_(std::move(allowedOrigins)) {}

std::optional<std::string> CorsConfig::resolveAllowOrigin(
std::string_view requestOrigin) const {
if (requestOrigin.empty()) return std::nullopt;
for (const auto& whitelisted : allowedOrigins_) {
if (requestOrigin == whitelisted) {
return whitelisted;
}
}
return std::nullopt;
}

} // namespace beepbox
20 changes: 20 additions & 0 deletions src/server_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "beepbox/RateLimiter.h"
#include "beepbox/Metrics.h"
#include "beepbox/Tracing.h"
#include "beepbox/CorsConfig.h"

#include <chrono>
#include <csignal>
Expand Down Expand Up @@ -145,6 +146,25 @@ int main() {
std::cout << "WARNING: BEEPBOX_RATE_LIMIT_RPM not set — rate limiting disabled\n";
}

// --- CORS setup ---
// Whitelist comes from BEEPBOX_CORS_ALLOWED_ORIGINS (CSV). Empty/unset
// → no advice registered → request pipeline behaves identically to
// pre-1794 server-to-server-only mode.
{
const char* corsEnv = std::getenv("BEEPBOX_CORS_ALLOWED_ORIGINS");
beepbox::CorsConfig corsCfg(beepbox::CorsConfig::parseAllowedOrigins(
corsEnv ? std::string_view{corsEnv} : std::string_view{}));
if (corsCfg.enabled()) {
std::cout << "CORS enabled for " << corsCfg.allowedOrigins().size()
<< " origin(s):";
for (const auto& o : corsCfg.allowedOrigins()) std::cout << " " << o;
std::cout << "\n";
beepbox::installCorsHandlers(std::move(corsCfg));
} else {
std::cout << "CORS disabled (BEEPBOX_CORS_ALLOWED_ORIGINS not set)\n";
}
}

// --- /healthz — liveness probe ---
app().registerHandler(
"/healthz",
Expand Down
93 changes: 93 additions & 0 deletions tests/CorsConfigTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright Beeping contributors

#include "beepbox/CorsConfig.h"

#include <catch2/catch_test_macros.hpp>

using beepbox::CorsConfig;

TEST_CASE("CorsConfig::parseAllowedOrigins", "[cors]") {
SECTION("empty string yields no entries") {
REQUIRE(CorsConfig::parseAllowedOrigins("").empty());
}

SECTION("single origin") {
auto v = CorsConfig::parseAllowedOrigins("http://localhost:3000");
REQUIRE(v.size() == 1);
REQUIRE(v[0] == "http://localhost:3000");
}

SECTION("multiple origins, trims whitespace, drops empties") {
auto v = CorsConfig::parseAllowedOrigins(
" http://localhost:3000 ,https://beeping.io,, https://www.beeping.io ");
REQUIRE(v.size() == 3);
REQUIRE(v[0] == "http://localhost:3000");
REQUIRE(v[1] == "https://beeping.io");
REQUIRE(v[2] == "https://www.beeping.io");
}

SECTION("preserves input order") {
auto v = CorsConfig::parseAllowedOrigins("c,a,b");
REQUIRE(v == std::vector<std::string>{"c", "a", "b"});
}

SECTION("a trailing comma does not introduce a phantom empty entry") {
auto v = CorsConfig::parseAllowedOrigins("http://localhost:3000,");
REQUIRE(v.size() == 1);
REQUIRE(v[0] == "http://localhost:3000");
}
}

TEST_CASE("CorsConfig::resolveAllowOrigin", "[cors]") {
CorsConfig cfg({
"http://localhost:3000",
"https://beeping-platform-dev.web.app",
"https://beeping.io",
});

SECTION("exact match returns the whitelisted entry") {
auto out = cfg.resolveAllowOrigin("http://localhost:3000");
REQUIRE(out.has_value());
REQUIRE(*out == "http://localhost:3000");
}

SECTION("any of the whitelisted origins resolve") {
REQUIRE(cfg.resolveAllowOrigin("https://beeping.io").has_value());
REQUIRE(cfg.resolveAllowOrigin("https://beeping-platform-dev.web.app")
.has_value());
}

SECTION("non-whitelisted origin returns nullopt") {
REQUIRE_FALSE(cfg.resolveAllowOrigin("https://evil.com").has_value());
}

SECTION("empty origin returns nullopt (no Origin header → not a CORS req)") {
REQUIRE_FALSE(cfg.resolveAllowOrigin("").has_value());
}

SECTION("scheme/host/port are matched verbatim — no fuzzy matching") {
// http vs https
REQUIRE_FALSE(cfg.resolveAllowOrigin("https://localhost:3000").has_value());
// different port
REQUIRE_FALSE(cfg.resolveAllowOrigin("http://localhost:3001").has_value());
// trailing slash mismatch
REQUIRE_FALSE(cfg.resolveAllowOrigin("http://localhost:3000/").has_value());
// case-sensitive (RFC 6454 origin comparison is case-sensitive on host)
REQUIRE_FALSE(cfg.resolveAllowOrigin("HTTP://localhost:3000").has_value());
}
}

TEST_CASE("CorsConfig::enabled", "[cors]") {
SECTION("default-constructed is disabled") {
REQUIRE_FALSE(CorsConfig{}.enabled());
}

SECTION("empty whitelist is disabled") {
REQUIRE_FALSE(CorsConfig{{}}.enabled());
}

SECTION("non-empty whitelist is enabled") {
REQUIRE(CorsConfig{{"http://localhost:3000"}}.enabled());
}
}
Loading