diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 0c2a255e..d7075715 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -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 . \ No newline at end of file + run: docker buildx build -t ghcr.io/${{ env.OWNER }}/strfry:latest --platform linux/amd64 --platform linux/arm64 --push . \ No newline at end of file diff --git a/src/apps/relay/RelayIngester.cpp b/src/apps/relay/RelayIngester.cpp index 6b1febad..1531c42d 100644 --- a/src/apps/relay/RelayIngester.cpp +++ b/src/apps/relay/RelayIngester.cpp @@ -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)}}); } diff --git a/src/apps/relay/golpe.yaml b/src/apps/relay/golpe.yaml index a845a1e6..1c64f9fc 100644 --- a/src/apps/relay/golpe.yaml +++ b/src/apps/relay/golpe.yaml @@ -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 diff --git a/src/filters.h b/src/filters.h index 8bcb8e25..f1b39092 100644 --- a/src/filters.h +++ b/src/filters.h @@ -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 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()) { diff --git a/strfry.conf b/strfry.conf index 7c07c67c..2a983cff 100644 --- a/strfry.conf +++ b/strfry.conf @@ -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 + } }