Skip to content

Commit 09f206c

Browse files
authored
Merge pull request #31 from PredicateSystems/issue_27
Issue 27
2 parents f6626db + 6ee7083 commit 09f206c

6 files changed

Lines changed: 244 additions & 21 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "predicate-authorityd"
3-
version = "0.6.7"
3+
version = "0.7.0"
44
edition = "2021"
55
description = "Rust-based sidecar daemon for Predicate Authority"
66
license = "MIT"

docs/sidecar-user-manual.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,8 +1020,9 @@ SSRF protection is **enabled by default**. Blocked requests return:
10201020

10211021
#### Whitelisting Local Services
10221022

1023-
To allow specific local endpoints (e.g., local LLM instances, databases), use the `--ssrf-allow` flag with host:port pairs:
1023+
To allow specific local endpoints (e.g., local LLM instances, databases), you have four options:
10241024

1025+
**Option 1: CLI flag** (highest precedence)
10251026
```bash
10261027
# Allow local Ollama (WSL2) and Elasticsearch
10271028
./predicate-authorityd \
@@ -1030,19 +1031,49 @@ To allow specific local endpoints (e.g., local LLM instances, databases), use th
10301031
run
10311032
```
10321033

1033-
Or via environment variable (comma-separated):
1034+
**Option 2: Environment variable**
10341035
```bash
10351036
export PREDICATE_SSRF_ALLOW="172.30.192.1:11434,127.0.0.1:9200"
10361037
./predicate-authorityd --policy-file policy.json run
10371038
```
10381039

1039-
Or in the configuration file:
1040+
**Option 3: TOML configuration file**
10401041
```toml
10411042
[ssrf]
10421043
allowed_endpoints = ["172.30.192.1:11434", "127.0.0.1:9200"]
10431044
```
10441045

1045-
**Important:** The whitelist is host:port specific to limit the exemption surface. Use exact matches only.
1046+
**Option 4: Policy file** (policy-driven, recommended for tenant-scoped deployments)
1047+
1048+
Add an `ssrf_whitelist` field to your policy JSON/YAML file:
1049+
1050+
```json
1051+
{
1052+
"ssrf_whitelist": ["172.30.192.1:11434", "127.0.0.1:9200"],
1053+
"rules": [
1054+
...
1055+
]
1056+
}
1057+
```
1058+
1059+
Or in YAML:
1060+
```yaml
1061+
ssrf_whitelist:
1062+
- "172.30.192.1:11434" # Local Ollama on WSL2
1063+
- "127.0.0.1:9200" # Local Elasticsearch
1064+
1065+
rules:
1066+
- name: allow-llm-calls
1067+
effect: allow
1068+
...
1069+
```
1070+
1071+
**Precedence and merging:**
1072+
- CLI and environment variables take highest precedence
1073+
- Entries from all sources are merged (deduplicated)
1074+
- If no whitelist is configured anywhere, full SSRF enforcement applies
1075+
1076+
**Important:** The whitelist uses exact `host:port` matching to limit the exemption surface. Only the specified port is allowed.
10461077

10471078
#### Disabling SSRF Protection
10481079

policies/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,59 @@ Patterns use glob-style matching:
289289
| `https://*` | Any HTTPS URL |
290290
| `/home/*/projects/**` | Any file under any user's projects dir |
291291

292+
#### Glob `**` Directory Matching Footgun
293+
294+
**Common mistake:** Using `**` to match a directory itself.
295+
296+
```json
297+
{
298+
"resources": ["model-eval/**"] // WRONG: matches files INSIDE model-eval, not the directory
299+
}
300+
```
301+
302+
The pattern `model-eval/**` matches `model-eval/file.txt` and `model-eval/sub/file.txt`, but it does **NOT** match the directory `model-eval` itself.
303+
304+
**To match both the directory and its contents:**
305+
306+
```json
307+
{
308+
"resources": ["model-eval", "model-eval/**"] // CORRECT: matches directory AND contents
309+
}
310+
```
311+
312+
Or use multiple patterns:
313+
- `model-eval` - matches the directory itself
314+
- `model-eval/*` - matches direct children
315+
- `model-eval/**` - matches all descendants recursively
316+
317+
### SSRF Whitelist (Policy-Driven)
318+
319+
You can include an optional `ssrf_whitelist` field in your policy file to allow specific local endpoints to bypass SSRF protection. This is useful for local LLMs (Ollama), databases, or other services running on private IPs.
320+
321+
```json
322+
{
323+
"ssrf_whitelist": ["172.30.192.1:11434", "127.0.0.1:9200"],
324+
"rules": [...]
325+
}
326+
```
327+
328+
**Key points:**
329+
- Whitelist uses exact `host:port` matching for security
330+
- If CLI `--ssrf-allow` is also provided, entries are merged
331+
- Defaults to empty (full SSRF enforcement) if omitted
332+
333+
**YAML example:**
334+
```yaml
335+
ssrf_whitelist:
336+
- "172.30.192.1:11434" # Local Ollama on WSL2
337+
- "127.0.0.1:9200" # Local Elasticsearch
338+
339+
rules:
340+
- name: allow-llm-calls
341+
effect: allow
342+
# ...
343+
```
344+
292345
---
293346

294347
## Creating Custom Policies

src/main.rs

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -484,29 +484,16 @@ async fn main() -> anyhow::Result<()> {
484484
// Initialize policy engine
485485
let policy_engine = PolicyEngine::new();
486486

487-
// Configure SSRF protection
487+
// Collect SSRF configuration from CLI and config file
488488
let ssrf_disabled = cli.ssrf_disabled || file_config.ssrf.disabled;
489-
let ssrf_allowed_endpoints: Vec<String> = if !cli.ssrf_allow.is_empty() {
489+
let mut ssrf_allowed_endpoints: Vec<String> = if !cli.ssrf_allow.is_empty() {
490490
cli.ssrf_allow.clone()
491491
} else {
492492
file_config.ssrf.allowed_endpoints.clone()
493493
};
494494

495-
if ssrf_disabled {
496-
policy_engine.set_ssrf_protection(None);
497-
warn!("SSRF protection disabled - all endpoints allowed");
498-
} else if !ssrf_allowed_endpoints.is_empty() {
499-
use predicate_authorityd::ssrf::SsrfProtection;
500-
let ssrf = SsrfProtection::new().with_allowed_endpoints(ssrf_allowed_endpoints.clone());
501-
policy_engine.set_ssrf_protection(Some(ssrf));
502-
info!(
503-
"SSRF protection enabled with {} allowed endpoints: {:?}",
504-
ssrf_allowed_endpoints.len(),
505-
ssrf_allowed_endpoints
506-
);
507-
}
508-
509495
// Load policy file if specified (supports JSON and YAML formats)
496+
// This must happen before SSRF setup to extract ssrf_whitelist from policy
510497
if let Some(ref policy_path) = policy_file {
511498
let format = policy_loader::detect_format(policy_path);
512499
info!(
@@ -526,6 +513,28 @@ async fn main() -> anyhow::Result<()> {
526513
info!("Loaded {} policy rules", count);
527514
}
528515

516+
// Merge ssrf_whitelist from policy file (if CLI/config didn't provide any)
517+
if !result.ssrf_whitelist.is_empty() {
518+
if ssrf_allowed_endpoints.is_empty() {
519+
ssrf_allowed_endpoints = result.ssrf_whitelist;
520+
info!(
521+
"SSRF whitelist loaded from policy file: {:?}",
522+
ssrf_allowed_endpoints
523+
);
524+
} else {
525+
// CLI/config takes precedence, but we can merge
526+
for endpoint in result.ssrf_whitelist {
527+
if !ssrf_allowed_endpoints.contains(&endpoint) {
528+
ssrf_allowed_endpoints.push(endpoint);
529+
}
530+
}
531+
info!(
532+
"SSRF whitelist merged with policy file entries: {:?}",
533+
ssrf_allowed_endpoints
534+
);
535+
}
536+
}
537+
529538
// Detect audit mode from policy file name
530539
let path_lower = policy_path.to_lowercase();
531540
if path_lower.contains("audit")
@@ -542,6 +551,21 @@ async fn main() -> anyhow::Result<()> {
542551
}
543552
}
544553

554+
// Configure SSRF protection (after policy loading to include policy-based whitelist)
555+
if ssrf_disabled {
556+
policy_engine.set_ssrf_protection(None);
557+
warn!("SSRF protection disabled - all endpoints allowed");
558+
} else if !ssrf_allowed_endpoints.is_empty() {
559+
use predicate_authorityd::ssrf::SsrfProtection;
560+
let ssrf = SsrfProtection::new().with_whitelist(ssrf_allowed_endpoints.clone());
561+
policy_engine.set_ssrf_protection(Some(ssrf));
562+
info!(
563+
"SSRF protection enabled with {} allowed endpoints: {:?}",
564+
ssrf_allowed_endpoints.len(),
565+
ssrf_allowed_endpoints
566+
);
567+
}
568+
545569
// Enable audit mode if explicitly requested via CLI
546570
if cli.audit_mode {
547571
policy_engine.set_audit_mode(true);

src/policy_loader.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ pub struct PolicyLoadResult {
6060
pub skipped_rules: usize,
6161
/// Whether the policy was cryptographically signed
6262
pub is_signed: bool,
63+
/// SSRF whitelist from policy file (optional, host:port format)
64+
/// Example: ["172.30.192.1:11434", "127.0.0.1:9200"]
65+
pub ssrf_whitelist: Vec<String>,
6366
}
6467

6568
/// Detect the format of a policy file based on its extension.
@@ -113,11 +116,23 @@ pub fn load_policy_from_string(
113116

114117
let skipped_rules = total_rules - parsed_rules.len();
115118

119+
// Extract optional ssrf_whitelist array (host:port format)
120+
let ssrf_whitelist: Vec<String> = json_value
121+
.get("ssrf_whitelist")
122+
.and_then(|v| v.as_array())
123+
.map(|arr| {
124+
arr.iter()
125+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
126+
.collect()
127+
})
128+
.unwrap_or_default();
129+
116130
Ok(PolicyLoadResult {
117131
rules: parsed_rules,
118132
format,
119133
skipped_rules,
120134
is_signed: false,
135+
ssrf_whitelist,
121136
})
122137
}
123138

@@ -425,5 +440,83 @@ mod tests {
425440

426441
assert_eq!(result.rules.len(), 0);
427442
assert_eq!(result.skipped_rules, 0);
443+
assert!(result.ssrf_whitelist.is_empty());
444+
}
445+
446+
// --- SSRF whitelist tests (Issue #27 policy-driven approach) ---
447+
448+
#[test]
449+
fn test_ssrf_whitelist_from_json() {
450+
let policy_with_whitelist = r#"{
451+
"ssrf_whitelist": ["172.30.192.1:11434", "127.0.0.1:9200"],
452+
"rules": [
453+
{
454+
"name": "allow-all",
455+
"effect": "allow",
456+
"principals": ["*"],
457+
"actions": ["*"],
458+
"resources": ["*"]
459+
}
460+
]
461+
}"#;
462+
463+
let result = load_policy_from_string(policy_with_whitelist, PolicyFormat::Json).unwrap();
464+
465+
assert_eq!(result.rules.len(), 1);
466+
assert_eq!(result.ssrf_whitelist.len(), 2);
467+
assert!(result
468+
.ssrf_whitelist
469+
.contains(&"172.30.192.1:11434".to_string()));
470+
assert!(result
471+
.ssrf_whitelist
472+
.contains(&"127.0.0.1:9200".to_string()));
473+
}
474+
475+
#[test]
476+
fn test_ssrf_whitelist_from_yaml() {
477+
let yaml_with_whitelist = r#"
478+
ssrf_whitelist:
479+
- "172.30.192.1:11434"
480+
- "localhost:8787"
481+
rules:
482+
- name: allow-all
483+
effect: allow
484+
principals:
485+
- "*"
486+
actions:
487+
- "*"
488+
resources:
489+
- "*"
490+
"#;
491+
492+
let result = load_policy_from_string(yaml_with_whitelist, PolicyFormat::Yaml).unwrap();
493+
494+
assert_eq!(result.rules.len(), 1);
495+
assert_eq!(result.ssrf_whitelist.len(), 2);
496+
assert!(result
497+
.ssrf_whitelist
498+
.contains(&"172.30.192.1:11434".to_string()));
499+
assert!(result
500+
.ssrf_whitelist
501+
.contains(&"localhost:8787".to_string()));
502+
}
503+
504+
#[test]
505+
fn test_ssrf_whitelist_missing_defaults_to_empty() {
506+
// Policy without ssrf_whitelist field should have empty whitelist
507+
let result = load_policy_from_string(SAMPLE_JSON_POLICY, PolicyFormat::Json).unwrap();
508+
assert!(result.ssrf_whitelist.is_empty());
509+
}
510+
511+
#[test]
512+
fn test_ssrf_whitelist_empty_array() {
513+
let policy_with_empty_whitelist = r#"{
514+
"ssrf_whitelist": [],
515+
"rules": []
516+
}"#;
517+
518+
let result =
519+
load_policy_from_string(policy_with_empty_whitelist, PolicyFormat::Json).unwrap();
520+
assert!(result.ssrf_whitelist.is_empty());
428521
}
429522
}

src/ssrf.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ impl SsrfProtection {
7878
self
7979
}
8080

81+
/// Alias for `with_allowed_endpoints` - adds whitelist entries that bypass SSRF checks
82+
/// This is the method name used when loading from policy files
83+
pub fn with_whitelist(self, whitelist: Vec<String>) -> Self {
84+
self.with_allowed_endpoints(whitelist)
85+
}
86+
8187
/// Add a single allowed endpoint
8288
pub fn add_allowed_endpoint(&mut self, endpoint: &str) {
8389
self.allowed_endpoints.push(endpoint.to_lowercase());
@@ -593,4 +599,20 @@ mod tests {
593599
assert!(ssrf.block_cloud_metadata);
594600
assert!(ssrf.block_internal_dns);
595601
}
602+
603+
#[test]
604+
fn test_with_whitelist_alias() {
605+
// Test that with_whitelist() is an alias for with_allowed_endpoints()
606+
let ssrf = SsrfProtection::new().with_whitelist(vec!["172.30.192.1:11434".to_string()]);
607+
608+
// Private IP would normally be blocked
609+
assert!(SsrfProtection::new()
610+
.check_resource("http://172.30.192.1:11434/api/generate")
611+
.is_some());
612+
613+
// But with_whitelist should allow it
614+
assert!(ssrf
615+
.check_resource("http://172.30.192.1:11434/api/generate")
616+
.is_none());
617+
}
596618
}

0 commit comments

Comments
 (0)