Skip to content
Merged
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
25 changes: 21 additions & 4 deletions dns-server/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import Optional

from app.config import (
DNS_PORT, SYNC_INTERVAL, HEARTBEAT_INTERVAL, LOG_LEVEL,
DNS_PORT, SYNC_INTERVAL, HEARTBEAT_INTERVAL, LOG_LEVEL, JWT_PUBLIC_KEY,
SQUAWK_RATE_LIMIT_ENABLED, SQUAWK_RATE_LIMIT_RPS, SQUAWK_RATE_LIMIT_BURST,
SQUAWK_RATE_LIMIT_BACKEND
)
Expand All @@ -21,6 +21,8 @@
from app.utils.resilience import ResilienceManager
from app.services.prometheus_metrics import init_prometheus_metrics
from app.services.http3_serving import build_serving_config
from app.utils.domain_policy import matches_policy
from app.utils.jwt_verify import verify_squawk_jwt
from app.services.rate_limiter import RateLimiter

# Configure logging
Expand Down Expand Up @@ -129,9 +131,6 @@ async def dns_query():
# Verify JWT and extract identity for rate limiting (if token provided)
token_identity = None
if token:
from app.utils.jwt_verify import verify_squawk_jwt
from app.config import JWT_PUBLIC_KEY

payload = verify_squawk_jwt(token, JWT_PUBLIC_KEY)
if payload:
token_identity = payload.get('sub') # Use subject (user ID) as identity
Expand Down Expand Up @@ -176,6 +175,24 @@ async def dns_query():
)
return jsonify({'Status': 3, 'Question': [{'name': domain, 'type': record_type}], 'Answer': []}), 200

# Check DNS domain policy (per-identity allowlist)
if token:
payload = verify_squawk_jwt(token, JWT_PUBLIC_KEY)
if payload:
allowed_domains = payload.get('dns_domains')
if not matches_policy(domain, allowed_domains):
logger.info(f"Domain policy denial for {domain}: dns_domains={allowed_domains}")
metrics_reporter.record_policy_denial('policy_denied')
metrics_reporter.record_query(
domain=domain,
record_type=record_type,
status='policy_denied',
response_time=0.0,
cache_hit=False,
source=token or 'unknown'
)
return jsonify({'Status': 3, 'Question': [{'name': domain, 'type': record_type}], 'Answer': []}), 200

# Check IOC feeds
if ioc_checker.is_blocked(domain):
logger.warning(f"Blocked IOC domain: {domain}")
Expand Down
15 changes: 15 additions & 0 deletions dns-server/app/services/prometheus_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ def _init_metrics(self):
registry=self.registry,
)

self.dns_policy_denials = Counter(
"squawk_dns_policy_denials_total",
"Total DNS queries denied by domain policy",
["outcome"],
registry=self.registry,
)

# System Resource Metrics
self.dns_memory_usage_bytes = Gauge(
"squawk_dns_memory_usage_bytes", "Memory usage in bytes",
Expand Down Expand Up @@ -273,6 +280,14 @@ def record_authentication_failure(self, failure_type: str):
"""Record authentication failure"""
self.dns_authentication_failures.labels(failure_type=failure_type).inc()

def record_policy_denial(self, outcome: str):
"""Record DNS query denied by domain policy.

Args:
outcome: Denial reason (e.g., 'policy_denied', 'empty_policy')
"""
self.dns_policy_denials.labels(outcome=outcome).inc()

def record_rate_limited_query(
self,
domain: str,
Expand Down
62 changes: 62 additions & 0 deletions dns-server/app/utils/domain_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""DNS domain policy matching for per-identity allowlists.

Implements case-insensitive, trailing-dot-normalized exact and wildcard suffix
matching. Wildcards (*.example.com) match any depth under the suffix but not
the suffix itself.
"""

from typing import Optional, List


def normalize_domain(domain: str) -> str:
"""Normalize domain: lowercase, remove trailing dot."""
return domain.lower().rstrip('.')


def matches_policy(queried_name: str, allowed_domains: Optional[List[str]]) -> bool:
"""
Check if queried domain name matches policy allowlist.

Args:
queried_name: The DNS query name (e.g., "example.com", "sub.example.com")
allowed_domains: List of allowed FQDNs or *.suffix wildcards, or None (unrestricted)
Empty list denies all.

Returns:
True if queried_name is allowed, False otherwise.

Matching rules (case-insensitive, trailing-dot normalized):
- None: unrestricted (returns True)
- []: deny all (returns False)
- Exact match: "example.com" matches exactly "example.com" (not "sub.example.com")
- Wildcard suffix: "*.example.com" matches "sub.example.com", "a.b.example.com"
but NOT "example.com" itself
"""
if allowed_domains is None:
# NULL = unrestricted
return True

if not allowed_domains:
# Empty list = deny all
return False

normalized_query = normalize_domain(queried_name)

for entry in allowed_domains:
normalized_entry = normalize_domain(entry)

# Exact match
if normalized_query == normalized_entry:
return True

# Wildcard suffix: *.example.com matches a.example.com, b.c.example.com, etc.
if normalized_entry.startswith('*.'):
suffix = normalized_entry[2:] # Remove '*.'
# Must match a subdomain: ends with .suffix but not equal to suffix
if normalized_query.endswith('.' + suffix) or normalized_query == suffix:
# Check: if it's exactly the suffix, wildcard doesn't match
if normalized_query == suffix:
continue
return True

return False
115 changes: 115 additions & 0 deletions dns-server/tests/test_domain_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Tests for DNS domain policy matching."""

import pytest
from app.utils.domain_policy import matches_policy, normalize_domain


class TestDomainPolicyMatching:
"""Test domain policy matching logic."""

def test_normalize_domain_lowercase(self):
"""Normalize domain to lowercase."""
assert normalize_domain("Example.COM") == "example.com"
assert normalize_domain("SUB.EXAMPLE.COM") == "sub.example.com"

def test_normalize_domain_trailing_dot(self):
"""Normalize domain removes trailing dot."""
assert normalize_domain("example.com.") == "example.com"
assert normalize_domain("sub.example.com.") == "sub.example.com"

def test_unrestricted_none(self):
"""NULL allowed_domains = unrestricted (return True)."""
assert matches_policy("example.com", None) is True
assert matches_policy("anything.com", None) is True
assert matches_policy("sub.sub.example.com", None) is True

def test_deny_all_empty_list(self):
"""Empty list = deny all (return False)."""
assert matches_policy("example.com", []) is False
assert matches_policy("sub.example.com", []) is False

def test_exact_match(self):
"""Exact match: exact FQDN only."""
allowed = ["example.com"]
assert matches_policy("example.com", allowed) is True
assert matches_policy("sub.example.com", allowed) is False
assert matches_policy("example.org", allowed) is False

def test_exact_match_case_insensitive(self):
"""Exact match is case-insensitive."""
allowed = ["Example.COM"]
assert matches_policy("example.com", allowed) is True
assert matches_policy("EXAMPLE.COM", allowed) is True
assert matches_policy("ExAmPlE.cOm", allowed) is True

def test_exact_match_trailing_dot_normalized(self):
"""Exact match normalizes trailing dots."""
allowed = ["example.com."]
assert matches_policy("example.com", allowed) is True
assert matches_policy("example.com.", allowed) is True

def test_wildcard_suffix_match(self):
"""Wildcard suffix *.example.com matches subdomains."""
allowed = ["*.example.com"]
assert matches_policy("sub.example.com", allowed) is True
assert matches_policy("a.b.example.com", allowed) is True
assert matches_policy("a.b.c.d.example.com", allowed) is True

def test_wildcard_suffix_does_not_match_base(self):
"""Wildcard *.example.com does NOT match example.com itself."""
allowed = ["*.example.com"]
assert matches_policy("example.com", allowed) is False

def test_wildcard_suffix_case_insensitive(self):
"""Wildcard suffix matching is case-insensitive."""
allowed = ["*.Example.COM"]
assert matches_policy("sub.example.com", allowed) is True
assert matches_policy("SUB.EXAMPLE.COM", allowed) is True

def test_wildcard_suffix_trailing_dot_normalized(self):
"""Wildcard suffix normalizes trailing dots."""
allowed = ["*.example.com."]
assert matches_policy("sub.example.com", allowed) is True
assert matches_policy("sub.example.com.", allowed) is True

def test_multiple_entries(self):
"""Multiple entries in allowlist."""
allowed = ["example.com", "*.test.org", "specific.other.net"]
assert matches_policy("example.com", allowed) is True
assert matches_policy("sub.test.org", allowed) is True
assert matches_policy("specific.other.net", allowed) is True
assert matches_policy("other.org", allowed) is False

def test_deep_wildcard_match(self):
"""Wildcard matches arbitrarily deep subdomains."""
allowed = ["*.example.com"]
assert matches_policy("a.b.c.d.e.f.example.com", allowed) is True

def test_no_match_similar_domain(self):
"""Similar domain names don't match."""
allowed = ["example.com"]
assert matches_policy("example.co", allowed) is False
assert matches_policy("exampleX.com", allowed) is False
assert matches_policy("example-com", allowed) is False

def test_no_match_parent_domain(self):
"""Parent domain doesn't match wildcard for subdomain."""
allowed = ["*.example.com"]
assert matches_policy("com", allowed) is False
assert matches_policy("example.com", allowed) is False
# But exact match of parent should not match the wildcard

def test_mixed_exact_and_wildcard(self):
"""Mix of exact and wildcard entries."""
allowed = ["example.com", "*.test.org", "static.value.net"]
assert matches_policy("example.com", allowed) is True
assert matches_policy("sub.example.com", allowed) is False
assert matches_policy("api.test.org", allowed) is True
assert matches_policy("static.value.net", allowed) is True
assert matches_policy("static.value.net.co", allowed) is False

def test_whitespace_in_domain_not_normalized(self):
"""Domains with whitespace are not valid (not normalized)."""
allowed = ["example.com"]
# Whitespace is not normalized away
assert matches_policy("example. com", allowed) is False
33 changes: 33 additions & 0 deletions manager/backend/alembic/versions/010_dns_domain_allowlists.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Add DNS domain allowlists — per-identity DNS domain allowlists enforced at DoH.

Revision ID: 010_dns_domain_allowlists
Revises: 009_oidc_trust_anchors
"""
from alembic import op
from sqlalchemy import Column, Text

revision = "010_dns_domain_allowlists"
down_revision = "009_oidc_trust_anchors"
branch_labels = None
depends_on = None


def upgrade() -> None:
"""Add allowed_domains column to machine_client and oidc_trust_anchor tables."""
# Add to machine_client
op.add_column(
'machine_client',
Column('allowed_domains', Text, nullable=True)
)

# Add to oidc_trust_anchor
op.add_column(
'oidc_trust_anchor',
Column('allowed_domains', Text, nullable=True)
)


def downgrade() -> None:
"""Remove allowed_domains columns."""
op.drop_column('machine_client', 'allowed_domains')
op.drop_column('oidc_trust_anchor', 'allowed_domains')
26 changes: 24 additions & 2 deletions manager/backend/app/blueprints/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.services.auth_service import AuthService
from app.middleware.auth import token_required, get_current_user
from app.utils.decorators import validate_json, audit_log
import json

auth_bp = Blueprint('auth', __name__)

Expand Down Expand Up @@ -328,11 +329,23 @@ def token():
else:
granted_scopes = client['scopes']

# Fetch allowed_domains from DB and parse JSON
db = current_app.db
mc_record = db((db.machine_client.client_id == client['client_id']) &
(db.machine_client.active == True)).select().first()
allowed_domains = None
if mc_record and mc_record.allowed_domains:
try:
allowed_domains = json.loads(mc_record.allowed_domains)
except (json.JSONDecodeError, TypeError):
allowed_domains = None

# Issue token
access_token = AuthService.create_machine_access_token(
client_id=client['client_id'],
tenant=client['tenant'],
granted_scopes=granted_scopes
granted_scopes=granted_scopes,
allowed_domains=allowed_domains
)

# Update last_used_at
Expand Down Expand Up @@ -422,11 +435,20 @@ def token():
else:
granted_scopes = trust_anchor.allowed_scopes

# Fetch allowed_domains from trust anchor and parse JSON
allowed_domains = None
if trust_anchor.allowed_domains:
try:
allowed_domains = json.loads(trust_anchor.allowed_domains)
except (json.JSONDecodeError, TypeError):
allowed_domains = None

# Issue token (with machine marker, tenant from anchor)
access_token = AuthService.create_machine_access_token(
client_id=f"oidc:{subject}", # Synthetic client_id for logging
tenant=trust_anchor.tenant,
granted_scopes=granted_scopes
granted_scopes=granted_scopes,
allowed_domains=allowed_domains
)

ttl_config = current_app.config.get('MACHINE_ACCESS_TOKEN_EXPIRES')
Expand Down
Loading
Loading