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
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ add_library(flapi-lib STATIC
src/query_executor.cpp
src/request_handler.cpp
src/request_validator.cpp
src/rate_limit_key_builder.cpp
src/rate_limit_middleware.cpp
src/route_translator.cpp
src/sql_template_processor.cpp
Expand Down
12 changes: 9 additions & 3 deletions src/config_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,8 @@ void ConfigManager::parseEndpointRateLimit(const YAML::Node& endpoint_config, En
endpoint.rate_limit.enabled = safeGet<bool>(rate_limit_node, "enabled", "rate-limit.enabled", false);
endpoint.rate_limit.max = safeGet<int>(rate_limit_node, "max", "rate-limit.max", 100);
endpoint.rate_limit.interval = safeGet<int>(rate_limit_node, "interval", "rate-limit.interval", 60);
endpoint.rate_limit.key_strategy = safeGet<std::string>(
rate_limit_node, "key", "rate-limit.key", rate_limit_config.key_strategy);
}
}

Expand Down Expand Up @@ -810,9 +812,13 @@ void ConfigManager::parseRateLimitConfig() {
rate_limit_config.enabled = rate_limit_node["enabled"].as<bool>();
rate_limit_config.max = rate_limit_node["max"].as<int>();
rate_limit_config.interval = rate_limit_node["interval"].as<int>();
CROW_LOG_DEBUG << "Rate Limit: enabled=" << rate_limit_config.enabled
<< ", max=" << rate_limit_config.max
<< ", interval=" << rate_limit_config.interval;
if (rate_limit_node["key"]) {
rate_limit_config.key_strategy = rate_limit_node["key"].as<std::string>();
}
CROW_LOG_DEBUG << "Rate Limit: enabled=" << rate_limit_config.enabled
<< ", max=" << rate_limit_config.max
<< ", interval=" << rate_limit_config.interval
<< ", key=" << rate_limit_config.key_strategy;
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/include/config_manager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ struct RateLimitConfig {
bool enabled = false;
int max = 100;
int interval = 60;
// W1.4: how requests are grouped into buckets. "ip" preserves the
// historic default; "user" / "user-or-ip" enable per-principal limits.
std::string key_strategy = "ip";
};

struct AuthUser {
Expand Down
41 changes: 41 additions & 0 deletions src/include/rate_limit_key_builder.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#pragma once

#include <string>

namespace flapi {

// W1.4: How the rate-limit middleware groups requests into buckets.
// `Ip` is the historic default (per-client-ip + per-path).
// `User` keys on the caller's `Authorization` header (hashed); all
// requests carrying the same Authorization land in the same bucket
// regardless of IP. Anonymous requests share one anonymous bucket.
// `UserOrIp` uses the user identifier when an Authorization header is
// present and falls back to the IP otherwise — useful when a deployment
// mixes authenticated and anonymous traffic.
enum class RateLimitKeyStrategy {
Ip,
User,
UserOrIp,
};

class RateLimitKeyStrategyUtils {
public:
// Parse a config string into the enum. Unknown / empty values fall
// back to `Ip` so existing configs keep working unchanged.
static RateLimitKeyStrategy parse(const std::string& value);
static std::string toString(RateLimitKeyStrategy strategy);
};

// Pure helper: build the bucket key used by RateLimitMiddleware.
// The output is deterministic, never contains the raw Authorization
// value (it is hashed), and always includes the path so that two
// endpoints stay in separate buckets even for the same caller.
class RateLimitKeyBuilder {
public:
std::string buildKey(RateLimitKeyStrategy strategy,
const std::string& client_ip,
const std::string& authorization_header,
const std::string& path) const;
};

} // namespace flapi
76 changes: 76 additions & 0 deletions src/rate_limit_key_builder.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#include "rate_limit_key_builder.hpp"

#include <functional>
#include <sstream>

namespace flapi {

namespace {

constexpr const char* kAnonymousMarker = "anonymous";

std::string hashAuthHeader(const std::string& authorization_header) {
// Stable, non-cryptographic hash of the Authorization header. We do
// not need cryptographic strength — we only need:
// 1) determinism so the same caller hashes to the same bucket
// 2) no plaintext token in the key (it gets logged at DEBUG)
// std::hash satisfies both for our purposes. Operators who want
// stronger guarantees should put a reverse proxy in front and key
// on a header it injects.
const std::size_t h = std::hash<std::string>{}(authorization_header);
std::ostringstream oss;
oss << "u" << std::hex << h;
return oss.str();
}

std::string principalForUserStrategy(const std::string& authorization_header) {
if (authorization_header.empty()) {
return std::string(kAnonymousMarker);
}
return hashAuthHeader(authorization_header);
}

} // namespace

RateLimitKeyStrategy RateLimitKeyStrategyUtils::parse(const std::string& value) {
if (value == "user") {
return RateLimitKeyStrategy::User;
}
if (value == "user-or-ip") {
return RateLimitKeyStrategy::UserOrIp;
}
// `ip`, empty, or unknown — preserve historical behaviour.
return RateLimitKeyStrategy::Ip;
}

std::string RateLimitKeyStrategyUtils::toString(RateLimitKeyStrategy strategy) {
switch (strategy) {
case RateLimitKeyStrategy::Ip: return "ip";
case RateLimitKeyStrategy::User: return "user";
case RateLimitKeyStrategy::UserOrIp: return "user-or-ip";
}
return "ip";
}

std::string RateLimitKeyBuilder::buildKey(RateLimitKeyStrategy strategy,
const std::string& client_ip,
const std::string& authorization_header,
const std::string& path) const {
std::string principal;
switch (strategy) {
case RateLimitKeyStrategy::Ip:
principal = client_ip;
break;
case RateLimitKeyStrategy::User:
principal = principalForUserStrategy(authorization_header);
break;
case RateLimitKeyStrategy::UserOrIp:
principal = authorization_header.empty()
? client_ip
: principalForUserStrategy(authorization_header);
break;
}
return principal + "|" + path;
}

} // namespace flapi
14 changes: 12 additions & 2 deletions src/rate_limit_middleware.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#include <algorithm>

#include "rate_limit_key_builder.hpp"

namespace flapi {

void RateLimitMiddleware::setConfig(std::shared_ptr<ConfigManager> config_manager) {
Expand All @@ -16,9 +18,17 @@ void RateLimitMiddleware::before_handle(crow::request& req, crow::response& res,
return;
}

// FIX #4: Include path in rate limit key (per-endpoint rate limits)
// W1.4: pick the bucket key based on the endpoint's `rate-limit.key`
// strategy (defaults to "ip" for backward compat).
std::string client_ip = req.remote_ip_address;
std::string rate_key = client_ip + "|" + req.url;
std::string auth_header;
auto it = req.headers.find("Authorization");
if (it != req.headers.end()) {
auth_header = it->second;
}
const auto strategy = RateLimitKeyStrategyUtils::parse(endpoint->rate_limit.key_strategy);
RateLimitKeyBuilder key_builder;
std::string rate_key = key_builder.buildKey(strategy, client_ip, auth_header, req.url);

{
std::lock_guard<std::mutex> lock(mutex);
Expand Down
1 change: 1 addition & 0 deletions test/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ add_executable(flapi_tests
mcp_prompt_handler_test.cpp
mcp_request_validator_test.cpp
query_executor_test.cpp
rate_limit_key_builder_test.cpp
rate_limit_middleware_test.cpp
request_handler_test.cpp
request_validator_test.cpp
Expand Down
121 changes: 121 additions & 0 deletions test/cpp/rate_limit_key_builder_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#include <catch2/catch_test_macros.hpp>

#include "rate_limit_key_builder.hpp"

namespace flapi {
namespace test {

namespace {

constexpr const char* kIp = "203.0.113.5";
constexpr const char* kBearer = "Bearer abc.def.ghi";
constexpr const char* kBearer2 = "Bearer xyz.uvw.rst";
constexpr const char* kPath = "/customers";

} // namespace

TEST_CASE("RateLimitKeyBuilder: ip strategy ignores the Authorization header",
"[security][ratelimit]") {
RateLimitKeyBuilder builder;
auto with_auth = builder.buildKey(RateLimitKeyStrategy::Ip, kIp, kBearer, kPath);
auto without_auth = builder.buildKey(RateLimitKeyStrategy::Ip, kIp, "", kPath);
REQUIRE(with_auth == without_auth);
REQUIRE(with_auth.find(kIp) != std::string::npos);
REQUIRE(with_auth.find(kPath) != std::string::npos);
}

TEST_CASE("RateLimitKeyBuilder: user strategy yields different keys for different tokens",
"[security][ratelimit]") {
// Two callers from the same IP with different bearer tokens must
// end up in different buckets when key=user.
RateLimitKeyBuilder builder;
auto a = builder.buildKey(RateLimitKeyStrategy::User, kIp, kBearer, kPath);
auto b = builder.buildKey(RateLimitKeyStrategy::User, kIp, kBearer2, kPath);
REQUIRE(a != b);
}

TEST_CASE("RateLimitKeyBuilder: user strategy ignores the client IP",
"[security][ratelimit]") {
// One user reaching the same endpoint from two IPs (mobile + Wi-Fi)
// must stay in a single bucket when key=user.
RateLimitKeyBuilder builder;
auto a = builder.buildKey(RateLimitKeyStrategy::User, "10.0.0.1", kBearer, kPath);
auto b = builder.buildKey(RateLimitKeyStrategy::User, "10.0.0.2", kBearer, kPath);
REQUIRE(a == b);
}

TEST_CASE("RateLimitKeyBuilder: user strategy with no auth header maps to anonymous bucket",
"[security][ratelimit]") {
// Anonymous callers must not share a bucket with any real user, but
// they DO share a single anonymous bucket among themselves — this is
// intentional: anonymous traffic is the easiest to abuse, so the
// tightest grouping is the safest default.
RateLimitKeyBuilder builder;
auto a = builder.buildKey(RateLimitKeyStrategy::User, kIp, "", kPath);
auto b = builder.buildKey(RateLimitKeyStrategy::User, "10.0.0.99", "", kPath);
REQUIRE(a == b);
REQUIRE(a.find("anonymous") != std::string::npos);
}

TEST_CASE("RateLimitKeyBuilder: user-or-ip falls back to ip when auth absent",
"[security][ratelimit]") {
RateLimitKeyBuilder builder;
auto user_or_ip_anon = builder.buildKey(RateLimitKeyStrategy::UserOrIp, kIp, "", kPath);
auto ip_only = builder.buildKey(RateLimitKeyStrategy::Ip, kIp, "", kPath);
REQUIRE(user_or_ip_anon == ip_only);
}

TEST_CASE("RateLimitKeyBuilder: user-or-ip uses auth when present",
"[security][ratelimit]") {
RateLimitKeyBuilder builder;
auto user_or_ip_auth = builder.buildKey(RateLimitKeyStrategy::UserOrIp, kIp, kBearer, kPath);
auto user_only_auth = builder.buildKey(RateLimitKeyStrategy::User, kIp, kBearer, kPath);
REQUIRE(user_or_ip_auth == user_only_auth);
}

TEST_CASE("RateLimitKeyBuilder: path is part of every key for per-endpoint isolation",
"[security][ratelimit]") {
// Two endpoints hit by the same caller stay in separate buckets.
RateLimitKeyBuilder builder;
auto a = builder.buildKey(RateLimitKeyStrategy::Ip, kIp, "", "/foo");
auto b = builder.buildKey(RateLimitKeyStrategy::Ip, kIp, "", "/bar");
REQUIRE(a != b);
}

TEST_CASE("RateLimitKeyBuilder: token derivation is stable across calls",
"[security][ratelimit]") {
// The hash must be deterministic; otherwise the same caller's
// requests would land in different buckets and the counter would
// never decrement properly.
RateLimitKeyBuilder builder;
auto a = builder.buildKey(RateLimitKeyStrategy::User, kIp, kBearer, kPath);
auto b = builder.buildKey(RateLimitKeyStrategy::User, kIp, kBearer, kPath);
REQUIRE(a == b);
}

TEST_CASE("RateLimitKeyBuilder: hashed auth token does not appear verbatim in the key",
"[security][ratelimit]") {
// The raw Authorization header value is sensitive; the key is
// logged at debug level and persisted in the per-process map. It
// must not contain the bearer token in plaintext.
RateLimitKeyBuilder builder;
auto key = builder.buildKey(RateLimitKeyStrategy::User, kIp,
"Bearer super-secret-token-abc123", kPath);
REQUIRE(key.find("super-secret-token-abc123") == std::string::npos);
}

TEST_CASE("RateLimitKeyStrategy::parse: known names map to the right enum",
"[security][ratelimit]") {
REQUIRE(RateLimitKeyStrategyUtils::parse("ip") == RateLimitKeyStrategy::Ip);
REQUIRE(RateLimitKeyStrategyUtils::parse("user") == RateLimitKeyStrategy::User);
REQUIRE(RateLimitKeyStrategyUtils::parse("user-or-ip") == RateLimitKeyStrategy::UserOrIp);
}

TEST_CASE("RateLimitKeyStrategy::parse: empty or unknown falls back to ip (backward compat)",
"[security][ratelimit]") {
REQUIRE(RateLimitKeyStrategyUtils::parse("") == RateLimitKeyStrategy::Ip);
REQUIRE(RateLimitKeyStrategyUtils::parse("garbage") == RateLimitKeyStrategy::Ip);
}

} // namespace test
} // namespace flapi
Loading
Loading