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.
This commit is contained in:
@@ -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 <id>`
|
||||
- `policy filtered out event <id> 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user