Skip to content
Open
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: 3 additions & 1 deletion .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,7 @@ jobs:
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v1
- name: Set lowercase owner
run: echo "OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Build the Docker image
run: docker buildx build -t ghcr.io/${{ github.repository_owner }}/strfry:latest --platform linux/amd64 --platform linux/arm64 --push .
run: docker buildx build -t ghcr.io/${{ env.OWNER }}/strfry:latest --platform linux/amd64 --platform linux/arm64 --push .
10 changes: 9 additions & 1 deletion src/apps/relay/RelayIngester.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,15 @@ void RelayServer::ingesterProcessReq(lmdb::txn &txn, uint64_t connId, const tao:
if (arr.get_array().size() < 2 + 1) throw herr("arr too small");
if (arr.get_array().size() > 2 + cfg().relay__maxReqFilterSize) throw herr("arr too big");

Subscription sub(connId, jsonGetString(arr[1], "REQ subscription id was not a string"), NostrFilterGroup(arr));
NostrFilterGroup filterGroup(arr);

try {
filterGroup.validateFilters();
} catch (std::exception &e) {
throw herr("filter validation failed: ", e.what());
}

Subscription sub(connId, jsonGetString(arr[1], "REQ subscription id was not a string"), std::move(filterGroup));

tpReqWorker.dispatch(connId, MsgReqWorker{MsgReqWorker::NewSub{std::move(sub)}});
}
Expand Down
19 changes: 19 additions & 0 deletions src/apps/relay/golpe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,22 @@ config:
- name: relay__negentropy__maxSyncEvents
desc: "Maximum records that sync will process before returning an error"
default: 1000000

- name: relay__filterValidation__enabled
desc: "Enable strict filter validation for REQ messages"
default: false
- name: relay__filterValidation__maxFiltersPerReq
desc: "Maximum number of filters allowed per REQ (when validation enabled)"
default: 3
- name: relay__filterValidation__minFiltersPerReq
desc: "Minimum number of filters required per REQ (when validation enabled)"
default: 1
- name: relay__filterValidation__maxKindsPerFilter
desc: "Maximum number of kinds allowed per filter (when validation enabled)"
default: 3
- name: relay__filterValidation__allowedKinds
desc: "Comma-separated list of allowed kinds (empty = all allowed)"
default: ""
- name: relay__filterValidation__requireAuthorOrTag
desc: "Require at least one author, p tag, or e tag in each filter"
default: false
77 changes: 77 additions & 0 deletions src/filters.h
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,83 @@ struct NostrFilterGroup {
}
}

void validateFilters(const std::string &ipAddr = "") const {
if (!cfg().relay__filterValidation__enabled) return;

size_t numFilters = filters.size();
if (numFilters < cfg().relay__filterValidation__minFiltersPerReq ||
numFilters > cfg().relay__filterValidation__maxFiltersPerReq) {
throw herr("invalid number of filters: ", numFilters);
}

std::vector<uint64_t> allowedKinds;
std::string allowedKindsStr = cfg().relay__filterValidation__allowedKinds;
if (!allowedKindsStr.empty()) {
size_t pos = 0;
while (pos < allowedKindsStr.size()) {
size_t nextComma = allowedKindsStr.find(',', pos);
if (nextComma == std::string::npos) nextComma = allowedKindsStr.size();

std::string kindStr = allowedKindsStr.substr(pos, nextComma - pos);
size_t start = kindStr.find_first_not_of(" \t");
size_t end = kindStr.find_last_not_of(" \t");
if (start != std::string::npos && end != std::string::npos) {
kindStr = kindStr.substr(start, end - start + 1);
if (!kindStr.empty()) {
allowedKinds.push_back(std::stoull(kindStr));
}
}

pos = nextComma + 1;
}
}

for (const auto &filter : filters) {
if (filter.kinds) {
size_t numKinds = filter.kinds->size();
if (numKinds > cfg().relay__filterValidation__maxKindsPerFilter) {
throw herr("too many kinds in filter: ", numKinds);
}

if (!allowedKinds.empty()) {
for (size_t i = 0; i < numKinds; i++) {
uint64_t kind = filter.kinds->at(i);
bool found = false;
for (auto allowedKind : allowedKinds) {
if (kind == allowedKind) {
found = true;
break;
}
}
if (!found) {
throw herr("kind not allowed: ", kind);
}
}
}
}

if (cfg().relay__filterValidation__requireAuthorOrTag) {
bool hasValidAuthor = filter.authors && filter.authors->size() == 1;
bool hasValidPTag = false;
bool hasValidETag = false;

auto pTagIt = filter.tags.find('p');
if (pTagIt != filter.tags.end()) {
hasValidPTag = pTagIt->second.size() == 1;
}

auto eTagIt = filter.tags.find('e');
if (eTagIt != filter.tags.end()) {
hasValidETag = eTagIt->second.size() == 1;
}

if (!hasValidAuthor && !hasValidPTag && !hasValidETag) {
throw herr("filter must have exactly one author, p tag, or e tag");
}
}
}
}

// FIXME refactor: Make unwrapped the default constructor
static NostrFilterGroup unwrapped(tao::json::value filter, uint64_t maxFilterLimit = cfg().relay__maxFilterLimit) {
if (!filter.is_array()) {
Expand Down
60 changes: 60 additions & 0 deletions strfry.conf
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,64 @@ relay {
# Maximum records that sync will process before returning an error
maxSyncEvents = 1000000
}

filterValidation {
# Enable strict filter validation for REQ messages
# When enabled, filters must meet the criteria below to be accepted
enabled = false

# Maximum number of filters allowed per REQ (when validation enabled)
# Example: REQ with 4 filters will be rejected if this is set to 3
#maxFiltersPerReq = 3

# Minimum number of filters required per REQ (when validation enabled)
# Example: REQ with 0 filters will be rejected if this is set to 1
#minFiltersPerReq = 1

# Maximum number of kinds allowed per filter (when validation enabled)
# Example: A filter with kinds [1,3,6,7] will be rejected if this is set to 3
#maxKindsPerFilter = 3

# Comma-separated list of allowed event kinds (empty = all kinds allowed)
# Example: "1,3,6,7" will only allow text notes, follows, reposts, and reactions
# Common kinds:
# 0 = Metadata, 1 = Text note, 3 = Contacts, 6 = Repost, 7 = Reaction
# 4 = Encrypted DM, 5 = Event deletion, 9735 = Zap, 10002 = Relay list
# 30023 = Long-form content, 13194 = Wallet info, 23194-23197 = NWC kinds
#allowedKinds = ""

# Require at least one author, p tag, or e tag in each filter
# This helps prevent expensive full-database scans
# When true, filters must have exactly one of:
# - One author pubkey
# - One "p" tag (pubkey tag)
# - One "e" tag (event ID tag)
#requireAuthorOrTag = false

## Example: Strict NWC relay configuration (similar to Alby's relay)
# enabled = true
# maxFiltersPerReq = 3
# minFiltersPerReq = 1
# maxKindsPerFilter = 3
# allowedKinds = "13194,23194,23195,23196,23197"
# requireAuthorOrTag = true

## Example: Public relay with anti-spam measures
## This allows common kinds but requires targeted queries:
# enabled = true
# maxFiltersPerReq = 3
# minFiltersPerReq = 1
# maxKindsPerFilter = 5
# allowedKinds = "0,1,3,4,5,6,7,9735,10002,30023"
# requireAuthorOrTag = true

## Example: Permissive relay with basic limits
## This only limits the number of filters and kinds:
# enabled = true
# maxFiltersPerReq = 5
# minFiltersPerReq = 1
# maxKindsPerFilter = 10
# allowedKinds = ""
# requireAuthorOrTag = false
}
}