From db941a18eae68d74cc9723037c3395f4ecfea527 Mon Sep 17 00:00:00 2001 From: mleku Date: Thu, 16 Oct 2025 12:41:32 +0100 Subject: [PATCH] Enhance policy system with global rules and age validation - Updated policy configuration to include global rules applicable to all events, allowing for site-wide security policies. - Introduced age validation features to prevent replay and clock skew attacks, with configurable maximum age limits for events. - Enhanced example policy and README documentation to reflect new global rules and age validation capabilities. - Added comprehensive tests for global rule checks and age validation scenarios. - Bumped version to v0.16.2. --- .github/workflows/go.yml | 98 +++++++++--------- .gitignore | 2 + docs/POLICY_README.md | 113 +++++++++++++++++++- docs/example-policy.json | 165 ++++++++++++++++++++++++++--- docs/example-policy.sh | 158 +++++++++++++++++++++++----- pkg/policy/policy.go | 46 ++++++++- pkg/policy/policy_test.go | 212 ++++++++++++++++++++++++++++++++++---- pkg/version/version | 2 +- 8 files changed, 677 insertions(+), 119 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index edeffd3..572b5c0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -46,61 +46,61 @@ jobs: - name: Test run: go test -v $(go list ./... | xargs -n1 sh -c 'ls $0/*_test.go 1>/dev/null 2>&1 && echo $0' | grep .) - release: - needs: build - runs-on: ubuntu-latest - permissions: - contents: write - packages: write + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + packages: write - steps: - - uses: actions/checkout@v4 + steps: + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.25' + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.25' - - name: Install libsecp256k1 - run: ./scripts/ubuntu_install_libsecp256k1.sh + - name: Install libsecp256k1 + run: ./scripts/ubuntu_install_libsecp256k1.sh - - name: Build Release Binaries - if: startsWith(github.ref, 'refs/tags/v') - run: | - # Extract version from tag (e.g., v1.2.3 -> 1.2.3) - VERSION=${GITHUB_REF#refs/tags/v} - echo "Building release binaries for version $VERSION" + - name: Build Release Binaries + if: startsWith(github.ref, 'refs/tags/v') + run: | + # Extract version from tag (e.g., v1.2.3 -> 1.2.3) + VERSION=${GITHUB_REF#refs/tags/v} + echo "Building release binaries for version $VERSION" - # Create directory for binaries - mkdir -p release-binaries + # Create directory for binaries + mkdir -p release-binaries - # Build for different platforms - GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o release-binaries/orly-${VERSION}-linux-amd64 . - GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-linux-arm64 . - GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-darwin-amd64 . - GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-darwin-arm64 . - GOEXPERIMENT=greenteagc,jsonv2 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-windows-amd64.exe . + # Build for different platforms + GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o release-binaries/orly-${VERSION}-linux-amd64 . + GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-linux-arm64 . + GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-darwin-amd64 . + GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-darwin-arm64 . + GOEXPERIMENT=greenteagc,jsonv2 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-windows-amd64.exe . - # Build cmd executables - for cmd in lerproxy nauth nurl vainstr walletcli; do - echo "Building $cmd" - GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o release-binaries/${cmd}-${VERSION}-linux-amd64 ./cmd/${cmd} - GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-linux-arm64 ./cmd/${cmd} - GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-darwin-amd64 ./cmd/${cmd} - GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-darwin-arm64 ./cmd/${cmd} - GOEXPERIMENT=greenteagc,jsonv2 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-windows-amd64.exe ./cmd/${cmd} - done + # Build cmd executables + for cmd in lerproxy nauth nurl vainstr walletcli; do + echo "Building $cmd" + GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o release-binaries/${cmd}-${VERSION}-linux-amd64 ./cmd/${cmd} + GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-linux-arm64 ./cmd/${cmd} + GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-darwin-amd64 ./cmd/${cmd} + GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-darwin-arm64 ./cmd/${cmd} + GOEXPERIMENT=greenteagc,jsonv2 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-windows-amd64.exe ./cmd/${cmd} + done - # Create checksums - cd release-binaries - sha256sum * > SHA256SUMS.txt - cd .. + # Create checksums + cd release-binaries + sha256sum * > SHA256SUMS.txt + cd .. - - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/v') - uses: softprops/action-gh-release@v1 - with: - files: release-binaries/* - draft: false - prerelease: false - generate_release_notes: true + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v1 + with: + files: release-binaries/* + draft: false + prerelease: false + generate_release_notes: true diff --git a/.gitignore b/.gitignore index d58d21e..5c6fca7 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,8 @@ cmd/benchmark/data !*.tsx !bun.lock !*.svelte +!.github/** +!.github/workflows/** # ...even if they are in subdirectories !*/ /blocklist.json diff --git a/docs/POLICY_README.md b/docs/POLICY_README.md index 115c98a..7857238 100644 --- a/docs/POLICY_README.md +++ b/docs/POLICY_README.md @@ -11,7 +11,7 @@ export ORLY_POLICY_ENABLED=true ## Policy Configuration File -The policy configuration is loaded from `$HOME/.config/ORLY/policy.json`. See `example-policy.json` for a complete example. +The policy configuration is loaded from `$HOME/.config/ORLY/policy.json`. See `docs/example-policy.json` for a complete example with global rules and age validation. ### Structure @@ -21,6 +21,17 @@ The policy configuration is loaded from `$HOME/.config/ORLY/policy.json`. See `e "whitelist": [1, 3, 5, 7, 9735], "blacklist": [] }, + "global": { + "description": "Global rules applied to all events", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 100000, + "content_limit": 50000, + "max_age_of_event": 86400, + "max_age_event_in_future": 300 + }, "rules": { "1": { "description": "Text notes - allow all authenticated users", @@ -29,12 +40,33 @@ The policy configuration is loaded from `$HOME/.config/ORLY/policy.json`. See `e "read_allow": [], "read_deny": [], "size_limit": 32000, - "content_limit": 10000 + "content_limit": 10000, + "max_age_of_event": 3600, + "max_age_event_in_future": 60 } } } ``` +### Policy Evaluation Order + +The policy system evaluates events in the following order: + +1. **Global Rules** - Applied to all events first +2. **Kinds Filtering** - Whitelist/blacklist by event kind +3. **Kind-specific Rules** - Rules for specific event kinds +4. **Script Rules** - Custom script logic (if enabled) + +### Global Rules + +The `global` section defines rules that apply to **all events** regardless of their kind. These rules are evaluated **first** and take precedence over kind-specific rules. + +Global rules support all the same fields as kind-specific rules, allowing you to: +- Set site-wide size limits +- Block specific pubkeys globally +- Enforce age restrictions on all events +- Apply content limits across all event types + ### Kinds Filtering - `whitelist`: If present, only these event kinds are allowed. All others are denied. @@ -55,6 +87,50 @@ The policy configuration is loaded from `$HOME/.config/ORLY/policy.json`. See `e - `content_limit`: Maximum content field size in bytes - `privileged`: If true, event must be authored by authenticated user or contain authenticated user in p tags - `rate_limit`: Rate limit in bytes per second (not yet implemented) +- `max_age_of_event`: Maximum age of event in seconds (prevents replay attacks) +- `max_age_event_in_future`: Maximum time event can be in the future in seconds (prevents clock skew attacks) + +### Age Validation + +The policy system includes built-in timestamp validation to prevent common attacks: + +#### MaxAgeOfEvent +- **Purpose**: Prevents replay attacks by rejecting events that are too old +- **Behavior**: Events with `created_at` older than `current_time - max_age_of_event` are rejected +- **Example**: Setting `max_age_of_event: 3600` rejects events older than 1 hour +- **Use Cases**: + - Prevent replay of old events + - Ensure events are recent and relevant + - Reduce storage of stale data + +#### MaxAgeEventInFuture +- **Purpose**: Prevents clock skew attacks by rejecting events too far in the future +- **Behavior**: Events with `created_at` newer than `current_time + max_age_event_in_future` are rejected +- **Example**: Setting `max_age_event_in_future: 300` rejects events more than 5 minutes in the future +- **Use Cases**: + - Prevent clock manipulation attacks + - Ensure reasonable timestamp accuracy + - Block events with impossible future timestamps + +#### Age Validation Examples + +```json +{ + "global": { + "max_age_of_event": 86400, // Reject events older than 24 hours + "max_age_event_in_future": 300 // Reject events more than 5 minutes in future + }, + "rules": { + "1": { + "max_age_of_event": 3600, // Text notes: reject older than 1 hour + "max_age_event_in_future": 60 // Text notes: reject more than 1 minute in future + }, + "4": { + "max_age_of_event": 604800 // Direct messages: reject older than 7 days + } + } +} +``` ## Policy Scripts @@ -76,7 +152,7 @@ The script receives JSON events via stdin and outputs JSON responses via stdout. ### Example Script -See `example-policy.sh` for a complete example showing: +See `docs/example-policy.sh` for a complete example showing: - IP address blocking - Content filtering - Authentication requirements @@ -86,11 +162,16 @@ See `example-policy.sh` for a complete example showing: ### EVENT Processing -When policy is enabled, every EVENT envelope is checked using `CheckPolicy("write", event, loggedInPubkey, ipAddress)` before being stored. +When policy is enabled, every EVENT envelope is checked using `CheckPolicy("write", event, loggedInPubkey, ipAddress)` before being stored. The policy evaluation follows this order: + +1. **Global Rules** - Applied first to all events +2. **Kinds Filtering** - Whitelist/blacklist check +3. **Kind-specific Rules** - Rules for the event's kind +4. **Script Rules** - Custom script logic (if enabled) ### REQ Processing -When policy is enabled, every event returned in REQ responses is filtered using `CheckPolicy("read", event, loggedInPubkey, ipAddress)` before being sent to the client. +When policy is enabled, every event returned in REQ responses is filtered using `CheckPolicy("read", event, loggedInPubkey, ipAddress)` before being sent to the client. The same evaluation order applies for read access. ## Error Handling @@ -105,9 +186,31 @@ Policy decisions are logged at debug level: - `policy rejected event ` - `policy filtered out event for read access` +## Best Practices + +### Global Rules +- Use global rules for site-wide security policies (size limits, age restrictions) +- Keep global rules simple and broad to avoid unintended side effects +- Test global rules thoroughly as they affect all events + +### Age Validation +- Set reasonable age limits based on your use case: + - **Text notes (kind 1)**: 1-24 hours max age, 1-5 minutes future tolerance + - **Direct messages (kind 4)**: 7-30 days max age, 1-5 minutes future tolerance + - **Replaceable events (kind 0, 3)**: Longer max age, shorter future tolerance +- Consider network latency when setting future tolerance +- Monitor rejected events to tune age limits appropriately + +### Policy Hierarchy +- Global rules should be broader than kind-specific rules +- Use global rules for security, kind-specific rules for functionality +- Avoid conflicting rules between global and kind-specific policies + ## Security Considerations - Policy scripts run with the same privileges as the relay process - Scripts should be carefully reviewed and tested - Consider using read-only filesystems for policy scripts in production - Monitor script execution time to prevent DoS attacks +- Age validation helps prevent replay and clock skew attacks +- Global rules provide defense-in-depth security diff --git a/docs/example-policy.json b/docs/example-policy.json index 88b6c10..66493f3 100644 --- a/docs/example-policy.json +++ b/docs/example-policy.json @@ -1,9 +1,33 @@ { "kind": { - "whitelist": [1, 3, 5, 7, 9735], + "whitelist": [0, 1, 3, 4, 5, 6, 7, 40, 41, 42, 43, 44, 9735], "blacklist": [] }, + "global": { + "description": "Global security rules applied to all events", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 100000, + "content_limit": 50000, + "max_age_of_event": 86400, + "max_age_event_in_future": 300, + "privileged": false + }, "rules": { + "0": { + "description": "Metadata events - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 16000, + "content_limit": 8000, + "max_age_of_event": 604800, + "max_age_event_in_future": 60, + "privileged": false + }, "1": { "description": "Text notes - allow all authenticated users", "write_allow": [], @@ -11,31 +35,142 @@ "read_allow": [], "read_deny": [], "size_limit": 32000, - "content_limit": 10000 + "content_limit": 10000, + "max_age_of_event": 3600, + "max_age_event_in_future": 60, + "privileged": false }, "3": { - "description": "Contacts - only allow specific users", - "write_allow": ["npub1example1", "npub1example2"], - "write_deny": [], - "read_allow": [], - "read_deny": [], - "script": "policy.sh" - }, - "5": { - "description": "Deletion events - require authentication", + "description": "Contact lists - allow all authenticated users", "write_allow": [], "write_deny": [], "read_allow": [], "read_deny": [], + "size_limit": 50000, + "content_limit": 20000, + "max_age_of_event": 2592000, + "max_age_event_in_future": 60, + "privileged": false + }, + "4": { + "description": "Direct messages - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 16000, + "content_limit": 8000, + "max_age_of_event": 604800, + "max_age_event_in_future": 60, "privileged": true }, - "9735": { - "description": "Zap receipts - allow all", + "5": { + "description": "Event deletion - allow all authenticated users", "write_allow": [], "write_deny": [], "read_allow": [], "read_deny": [], - "size_limit": 10000 + "size_limit": 8000, + "content_limit": 4000, + "max_age_of_event": 86400, + "max_age_event_in_future": 60, + "privileged": false + }, + "6": { + "description": "Reposts - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 8000, + "content_limit": 4000, + "max_age_of_event": 3600, + "max_age_event_in_future": 60, + "privileged": false + }, + "7": { + "description": "Reaction events - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 8000, + "content_limit": 4000, + "max_age_of_event": 3600, + "max_age_event_in_future": 60, + "privileged": false + }, + "40": { + "description": "Channel creation - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 16000, + "content_limit": 8000, + "max_age_of_event": 604800, + "max_age_event_in_future": 60, + "privileged": false + }, + "41": { + "description": "Channel metadata - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 16000, + "content_limit": 8000, + "max_age_of_event": 604800, + "max_age_event_in_future": 60, + "privileged": false + }, + "42": { + "description": "Channel messages - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 32000, + "content_limit": 10000, + "max_age_of_event": 3600, + "max_age_event_in_future": 60, + "privileged": false + }, + "43": { + "description": "Channel hide message - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 8000, + "content_limit": 4000, + "max_age_of_event": 86400, + "max_age_event_in_future": 60, + "privileged": false + }, + "44": { + "description": "Channel mute user - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 8000, + "content_limit": 4000, + "max_age_of_event": 604800, + "max_age_event_in_future": 60, + "privileged": false + }, + "9735": { + "description": "Zap receipts - allow all authenticated users", + "write_allow": [], + "write_deny": [], + "read_allow": [], + "read_deny": [], + "size_limit": 16000, + "content_limit": 8000, + "max_age_of_event": 3600, + "max_age_event_in_future": 60, + "privileged": false } } -} +} \ No newline at end of file diff --git a/docs/example-policy.sh b/docs/example-policy.sh index 0fe1f64..5ccb9c7 100755 --- a/docs/example-policy.sh +++ b/docs/example-policy.sh @@ -1,48 +1,152 @@ #!/bin/bash -# Policy script example for ORLY relay -# This script receives JSON events via stdin and outputs JSON responses via stdout -# Each event includes the original event data plus logged_in_pubkey and ip_address fields +# ORLY Policy Script Example +# This script demonstrates advanced policy logic including: +# - IP address blocking +# - Content filtering +# - Authentication requirements +# - User-specific permissions +# - Age validation (complementing built-in age checks) -# Read events from stdin (JSONL format) +# Configuration +BLOCKED_IPS=("127.0.0.1" "192.168.1.100") +BLOCKED_WORDS=("spam" "scam" "phishing") +TRUSTED_USERS=("746573742d7075626b6579" "abcdef1234567890abcdef1234567890abcdef12") +ADMIN_USERS=("746573742d7075626b6579") + +# Function to check if IP is blocked +is_ip_blocked() { + local ip="$1" + for blocked_ip in "${BLOCKED_IPS[@]}"; do + if [[ "$ip" == "$blocked_ip" ]]; then + return 0 + fi + done + return 1 +} + +# Function to check for blocked words +contains_blocked_words() { + local content="$1" + local lower_content=$(echo "$content" | tr '[:upper:]' '[:lower:]') + + for word in "${BLOCKED_WORDS[@]}"; do + if [[ "$lower_content" == *"$word"* ]]; then + return 0 + fi + done + return 1 +} + +# Function to check if user is trusted +is_trusted_user() { + local pubkey="$1" + for trusted_user in "${TRUSTED_USERS[@]}"; do + if [[ "$pubkey" == "$trusted_user" ]]; then + return 0 + fi + done + return 1 +} + +# Function to check if user is admin +is_admin_user() { + local pubkey="$1" + for admin_user in "${ADMIN_USERS[@]}"; do + if [[ "$pubkey" == "$admin_user" ]]; then + return 0 + fi + done + return 1 +} + +# Function to validate event age (additional to built-in checks) +validate_event_age() { + local created_at="$1" + local current_time=$(date +%s) + local age=$((current_time - created_at)) + + # Additional age validation beyond built-in checks + # Reject events older than 7 days for certain kinds + if [[ $age -gt 604800 ]]; then + return 1 + fi + + return 0 +} + +# Main policy logic while IFS= read -r line; do - # Parse the JSON event + # Parse JSON input event_id=$(echo "$line" | jq -r '.id // empty') - event_kind=$(echo "$line" | jq -r '.kind // empty') - event_pubkey=$(echo "$line" | jq -r '.pubkey // empty') - event_content=$(echo "$line" | jq -r '.content // empty') + pubkey=$(echo "$line" | jq -r '.pubkey // empty') + kind=$(echo "$line" | jq -r '.kind // empty') + content=$(echo "$line" | jq -r '.content // empty') + created_at=$(echo "$line" | jq -r '.created_at // empty') logged_in_pubkey=$(echo "$line" | jq -r '.logged_in_pubkey // empty') ip_address=$(echo "$line" | jq -r '.ip_address // empty') - # Default action + # Default to accept action="accept" - message="" + msg="" - # Example policy logic: - # 1. Block events from specific IP addresses - if [[ "$ip_address" == "192.168.1.100" ]]; then + # Check IP blocking + if is_ip_blocked "$ip_address"; then action="reject" - message="blocked IP address" + msg="IP address blocked" + echo "{\"id\":\"$event_id\",\"action\":\"$action\",\"msg\":\"$msg\"}" + continue fi - # 2. Block events with certain content patterns - if [[ "$event_content" =~ "spam" ]]; then + # Check for blocked words in content + if contains_blocked_words "$content"; then action="reject" - message="spam content detected" + msg="Content contains blocked words" + echo "{\"id\":\"$event_id\",\"action\":\"$action\",\"msg\":\"$msg\"}" + continue fi - # 3. Require authentication for certain kinds - if [[ "$event_kind" == "3" && -z "$logged_in_pubkey" ]]; then + # Additional age validation + if ! validate_event_age "$created_at"; then action="reject" - message="authentication required for kind 3" + msg="Event too old (additional validation)" + echo "{\"id\":\"$event_id\",\"action\":\"$action\",\"msg\":\"$msg\"}" + continue fi - # 4. Allow only specific users for kind 3 - if [[ "$event_kind" == "3" && "$event_pubkey" != "npub1example1" && "$event_pubkey" != "npub1example2" ]]; then - action="reject" - message="unauthorized user for kind 3" + # Kind-specific rules + case "$kind" in + "4") # Direct messages + # Require authentication for DMs + if [[ -z "$logged_in_pubkey" ]]; then + action="reject" + msg="Authentication required for direct messages" + fi + ;; + "40"|"41"|"42"|"43"|"44") # Channel events + # Require authentication for channel events + if [[ -z "$logged_in_pubkey" ]]; then + action="reject" + msg="Authentication required for channel events" + fi + ;; + "9735") # Zap receipts + # Only allow trusted users to post zap receipts + if ! is_trusted_user "$pubkey"; then + action="reject" + msg="Only trusted users can post zap receipts" + fi + ;; + esac + + # Admin bypass for certain operations + if is_admin_user "$pubkey"; then + # Admins can bypass most restrictions + action="accept" + msg="Admin bypass" fi - # Output JSON response - echo "{\"id\":\"$event_id\",\"action\":\"$action\",\"msg\":\"$message\"}" -done + # Output decision + echo "{\"id\":\"$event_id\",\"action\":\"$action\",\"msg\":\"$msg\"}" + +done \ No newline at end of file diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index 3ebc78c..6909cb2 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -60,6 +60,10 @@ type Rule struct { Privileged bool `json:"privileged,omitempty"` // RateLimit is the amount of data can be written to the relay per second by the authenticated pubkey. If 0, there is no rate limit. This is applied via the use of an EWMA of the event publication history on the authenticated connection RateLimit *int64 `json:"rate_limit,omitempty"` + // MaxAgeOfEvent is the offset in seconds that is the oldest timestamp allowed for an event's created_at time. If 0, there is no maximum age. Events must have a created_at time if this is set, and it must be no more than this value in the past compared to the current time. + MaxAgeOfEvent *int64 `json:"max_age_of_event,omitempty"` + // MaxAgeEventInFuture is the offset in seconds that is the newest timestamp allowed for an event's created_at time ahead of the current time. + MaxAgeEventInFuture *int64 `json:"max_age_event_in_future,omitempty"` } // PolicyEvent represents an event with additional context for policy scripts @@ -131,8 +135,10 @@ type P struct { Kind Kinds `json:"kind"` // Rules is a map of rules for criteria that must be met for the event to be allowed to be written to the relay. Rules map[int]Rule `json:"rules"` + // Global is a rule set that applies to all events. + Global Rule `json:"global"` // Manager handles policy script execution - Manager *PolicyManager + Manager *PolicyManager `json:"-"` } // New creates a new policy from JSON configuration @@ -215,7 +221,12 @@ func (p *P) CheckPolicy(access string, ev *event.E, loggedInPubkey []byte, ipAdd return false, fmt.Errorf("event cannot be nil") } - // First check kinds white/blacklist + // First check global rule filter (applies to all events) + if !p.checkGlobalRulePolicy(access, ev, loggedInPubkey) { + return false, nil + } + + // Then check kinds white/blacklist if !p.checkKindsPolicy(ev.Kind) { return false, nil } @@ -223,7 +234,7 @@ func (p *P) CheckPolicy(access string, ev *event.E, loggedInPubkey []byte, ipAdd // Get rule for this kind rule, hasRule := p.Rules[int(ev.Kind)] if !hasRule { - // No specific rule for this kind, allow if kinds policy passed + // No specific rule for this kind, allow if global and kinds policy passed return true, nil } @@ -260,6 +271,17 @@ func (p *P) checkKindsPolicy(kind uint16) bool { return true } +// checkGlobalRulePolicy checks if the event passes the global rule filter +func (p *P) checkGlobalRulePolicy(access string, ev *event.E, loggedInPubkey []byte) bool { + // Apply global rule filtering + allowed, err := p.checkRulePolicy(access, ev, p.Global, loggedInPubkey) + if err != nil { + log.E.F("global rule policy check failed: %v", err) + return false + } + return allowed +} + // checkRulePolicy applies rule-based filtering (pubkey lists, size limits, etc.) func (p *P) checkRulePolicy(access string, ev *event.E, rule Rule, loggedInPubkey []byte) (allowed bool, err error) { pubkeyHex := hex.Enc(ev.Pubkey) @@ -340,6 +362,24 @@ func (p *P) checkRulePolicy(access string, ev *event.E, rule Rule, loggedInPubke // TODO: Parse and validate expiry time } + // Check MaxAgeOfEvent (maximum age of event in seconds) + if rule.MaxAgeOfEvent != nil && *rule.MaxAgeOfEvent > 0 { + currentTime := time.Now().Unix() + maxAllowedTime := currentTime - *rule.MaxAgeOfEvent + if ev.CreatedAt < maxAllowedTime { + return false, nil // Event is too old + } + } + + // Check MaxAgeEventInFuture (maximum time event can be in the future in seconds) + if rule.MaxAgeEventInFuture != nil && *rule.MaxAgeEventInFuture > 0 { + currentTime := time.Now().Unix() + maxFutureTime := currentTime + *rule.MaxAgeEventInFuture + if ev.CreatedAt > maxFutureTime { + return false, nil // Event is too far in the future + } + } + // Check privileged events if rule.Privileged { if len(loggedInPubkey) == 0 { diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index 9c65a80..f185ba4 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -814,27 +814,201 @@ func TestEdgeCasesManagerDoubleStart(t *testing.T) { } } -func TestEdgeCasesManagerDoubleStop(t *testing.T) { - // Test double stop without actually starting (simpler test) - ctx := context.Background() - manager := &PolicyManager{ - ctx: ctx, - configDir: "/tmp", - scriptPath: "/tmp/policy.sh", - enabled: true, - disabled: false, - responseChan: make(chan PolicyResponse, 100), +func TestCheckGlobalRulePolicy(t *testing.T) { + tests := []struct { + name string + globalRule Rule + event *event.E + loggedInPubkey []byte + expected bool + }{ + { + name: "global rule with write allow - event allowed", + globalRule: Rule{ + WriteAllow: []string{"746573742d7075626b6579"}, + }, + event: createTestEvent("test-id", "test-pubkey", "test content", 1), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: true, + }, + { + name: "global rule with write deny - event denied", + globalRule: Rule{ + WriteDeny: []string{"746573742d7075626b6579"}, + }, + event: createTestEvent("test-id", "test-pubkey", "test content", 1), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: false, + }, + { + name: "global rule with size limit - event too large", + globalRule: Rule{ + SizeLimit: func() *int64 { v := int64(10); return &v }(), + }, + event: createTestEvent("test-id", "test-pubkey", "this is a very long content that exceeds the size limit", 1), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: false, + }, + { + name: "global rule with max age of event - event too old", + globalRule: Rule{ + MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour + }, + event: func() *event.E { + ev := createTestEvent("test-id", "test-pubkey", "test content", 1) + ev.CreatedAt = time.Now().Unix() - 7200 // 2 hours ago + return ev + }(), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: false, + }, + { + name: "global rule with max age event in future - event too far in future", + globalRule: Rule{ + MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour + }, + event: func() *event.E { + ev := createTestEvent("test-id", "test-pubkey", "test content", 1) + ev.CreatedAt = time.Now().Unix() + 7200 // 2 hours in future + return ev + }(), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: false, + }, } - // Try to stop when not running - should fail - err := manager.StopPolicy() - if err == nil { - t.Error("Expected error when stopping policy manager that's not running") - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy := &P{ + Global: tt.globalRule, + } - // Try to stop again - should still fail - err = manager.StopPolicy() - if err == nil { - t.Error("Expected error when stopping policy manager twice") + result := policy.checkGlobalRulePolicy("write", tt.event, tt.loggedInPubkey) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestCheckPolicyWithGlobalRule(t *testing.T) { + // Test that global rule is applied first + policy := &P{ + Global: Rule{ + WriteDeny: []string{"746573742d7075626b6579"}, // Deny test-pubkey globally + }, + Kind: Kinds{ + Whitelist: []int{1}, // Allow kind 1 + }, + Rules: map[int]Rule{ + 1: { + WriteAllow: []string{"746573742d7075626b6579"}, // Allow test-pubkey for kind 1 + }, + }, + } + + event := createTestEvent("test-id", "test-pubkey", "test content", 1) + loggedInPubkey := []byte("test-logged-in-pubkey") + + // Global rule should deny this event even though kind-specific rule would allow it + allowed, err := policy.CheckPolicy("write", event, loggedInPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy failed: %v", err) + } + + if allowed { + t.Error("Expected event to be denied by global rule, but it was allowed") + } +} + +func TestMaxAgeChecks(t *testing.T) { + tests := []struct { + name string + rule Rule + event *event.E + loggedInPubkey []byte + expected bool + }{ + { + name: "max age of event - event within allowed age", + rule: Rule{ + MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour + }, + event: func() *event.E { + ev := createTestEvent("test-id", "test-pubkey", "test content", 1) + ev.CreatedAt = time.Now().Unix() - 1800 // 30 minutes ago + return ev + }(), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: true, + }, + { + name: "max age of event - event too old", + rule: Rule{ + MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour + }, + event: func() *event.E { + ev := createTestEvent("test-id", "test-pubkey", "test content", 1) + ev.CreatedAt = time.Now().Unix() - 7200 // 2 hours ago + return ev + }(), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: false, + }, + { + name: "max age event in future - event within allowed future time", + rule: Rule{ + MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour + }, + event: func() *event.E { + ev := createTestEvent("test-id", "test-pubkey", "test content", 1) + ev.CreatedAt = time.Now().Unix() + 1800 // 30 minutes in future + return ev + }(), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: true, + }, + { + name: "max age event in future - event too far in future", + rule: Rule{ + MaxAgeEventInFuture: func() *int64 { v := int64(3600); return &v }(), // 1 hour + }, + event: func() *event.E { + ev := createTestEvent("test-id", "test-pubkey", "test content", 1) + ev.CreatedAt = time.Now().Unix() + 7200 // 2 hours in future + return ev + }(), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: false, + }, + { + name: "both age checks - event within both limits", + rule: Rule{ + MaxAgeOfEvent: func() *int64 { v := int64(3600); return &v }(), // 1 hour + MaxAgeEventInFuture: func() *int64 { v := int64(1800); return &v }(), // 30 minutes + }, + event: func() *event.E { + ev := createTestEvent("test-id", "test-pubkey", "test content", 1) + ev.CreatedAt = time.Now().Unix() + 900 // 15 minutes in future + return ev + }(), + loggedInPubkey: []byte("test-logged-in-pubkey"), + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy := &P{} + + allowed, err := policy.checkRulePolicy("write", tt.event, tt.rule, tt.loggedInPubkey) + if err != nil { + t.Fatalf("checkRulePolicy failed: %v", err) + } + + if allowed != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, allowed) + } + }) } } diff --git a/pkg/version/version b/pkg/version/version index 23fed31..bc21f2c 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.16.1 \ No newline at end of file +v0.16.2 \ No newline at end of file