secure-web/backend/scanner/scanners/headers.py

406 lines
15 KiB
Python

"""
HTTP Header Security Scanner.
This module analyzes HTTP response headers for security
best practices and common misconfigurations.
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
import httpx
from .base import (
BaseScanner,
ScannerResult,
ScannerStatus,
IssueData,
MetricData,
)
logger = logging.getLogger(__name__)
# Security header definitions with expected values and severity
SECURITY_HEADERS = {
'Strict-Transport-Security': {
'severity': 'high',
'description': 'HTTP Strict Transport Security (HSTS) forces browsers to use HTTPS.',
'remediation': (
'Add the header: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload'
),
'check_value': lambda v: 'max-age' in v.lower() and int(
v.lower().split('max-age=')[1].split(';')[0].strip()
) >= 31536000 if 'max-age=' in v.lower() else False,
},
'Content-Security-Policy': {
'severity': 'high',
'description': 'Content Security Policy (CSP) helps prevent XSS and data injection attacks.',
'remediation': (
"Implement a Content-Security-Policy header that restricts sources for scripts, "
"styles, and other resources. Start with a report-only policy to identify issues."
),
'check_value': lambda v: "default-src" in v.lower() or "script-src" in v.lower(),
},
'X-Content-Type-Options': {
'severity': 'medium',
'description': 'Prevents browsers from MIME-sniffing responses.',
'remediation': 'Add the header: X-Content-Type-Options: nosniff',
'check_value': lambda v: v.lower() == 'nosniff',
},
'X-Frame-Options': {
'severity': 'medium',
'description': 'Protects against clickjacking by controlling page framing.',
'remediation': 'Add the header: X-Frame-Options: DENY or SAMEORIGIN',
'check_value': lambda v: v.upper() in ['DENY', 'SAMEORIGIN'],
},
'Referrer-Policy': {
'severity': 'low',
'description': 'Controls how much referrer information is sent with requests.',
'remediation': (
'Add the header: Referrer-Policy: strict-origin-when-cross-origin '
'or no-referrer-when-downgrade'
),
'check_value': lambda v: v.lower() in [
'no-referrer', 'no-referrer-when-downgrade',
'strict-origin', 'strict-origin-when-cross-origin',
'same-origin', 'origin', 'origin-when-cross-origin'
],
},
'Permissions-Policy': {
'severity': 'low',
'description': 'Controls which browser features can be used.',
'remediation': (
'Add a Permissions-Policy header to restrict access to sensitive browser APIs '
'like geolocation, camera, and microphone.'
),
'check_value': lambda v: len(v) > 0,
},
'X-XSS-Protection': {
'severity': 'info',
'description': 'Legacy XSS filter (deprecated in modern browsers, CSP is preferred).',
'remediation': 'While deprecated, you can add: X-XSS-Protection: 1; mode=block',
'check_value': lambda v: '1' in v,
},
}
# CORS security checks
CORS_CHECKS = {
'permissive_origin': {
'severity': 'high',
'title': 'Overly permissive CORS (Access-Control-Allow-Origin: *)',
'description': (
'The server allows requests from any origin. This can expose sensitive data '
'to malicious websites if combined with credentials.'
),
'remediation': (
'Restrict Access-Control-Allow-Origin to specific trusted domains instead of using *. '
'Never use * with Access-Control-Allow-Credentials: true.'
),
},
'credentials_with_wildcard': {
'severity': 'critical',
'title': 'CORS allows credentials with wildcard origin',
'description': (
'The server has Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *. '
'This is a severe misconfiguration that can allow credential theft.'
),
'remediation': (
'Never combine Access-Control-Allow-Credentials: true with a wildcard origin. '
'Implement a whitelist of allowed origins.'
),
},
}
class HeaderScanner(BaseScanner):
"""
Scanner for HTTP security headers.
Checks for:
- Missing security headers
- Improperly configured headers
- CORS misconfigurations
- Cookie security flags
"""
name = "header_check"
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
self.timeout = self.config.get('timeout', 30)
def run(self, url: str) -> ScannerResult:
"""
Run header security analysis on the URL.
Args:
url: The URL to analyze
Returns:
ScannerResult with header findings
"""
self.logger.info(f"Starting header scan for {url}")
try:
# Make both GET and HEAD requests
headers_data = self._fetch_headers(url)
issues = []
metrics = []
# Check security headers
header_issues, header_score = self._check_security_headers(
headers_data['headers']
)
issues.extend(header_issues)
# Check CORS configuration
cors_issues = self._check_cors(headers_data['headers'], url)
issues.extend(cors_issues)
# Check cookies
cookie_issues = self._check_cookies(headers_data['headers'], url)
issues.extend(cookie_issues)
# Create metrics
metrics.append(MetricData(
name='security_headers_score',
display_name='Security Headers Score',
value=float(header_score),
unit='percent',
source='header_check'
))
metrics.append(MetricData(
name='headers_missing_count',
display_name='Missing Security Headers',
value=float(len([i for i in header_issues if 'missing' in i.title.lower()])),
unit='count',
source='header_check'
))
self.logger.info(
f"Header scan complete: {len(issues)} issues, score: {header_score}"
)
return ScannerResult(
scanner_name=self.name,
status=ScannerStatus.SUCCESS,
issues=issues,
metrics=metrics,
raw_data=headers_data
)
except httpx.TimeoutException:
return self._create_error_result(Exception("Header check timed out"))
except Exception as e:
return self._create_error_result(e)
def _fetch_headers(self, url: str) -> Dict[str, Any]:
"""Fetch headers from the URL."""
with httpx.Client(
timeout=self.timeout,
follow_redirects=True,
verify=True
) as client:
# GET request
get_response = client.get(url)
# HEAD request
head_response = client.head(url)
return {
'url': str(get_response.url),
'status_code': get_response.status_code,
'headers': dict(get_response.headers),
'head_headers': dict(head_response.headers),
'redirected': str(get_response.url) != url,
'redirect_history': [str(r.url) for r in get_response.history],
}
def _check_security_headers(
self,
headers: Dict[str, str]
) -> Tuple[List[IssueData], int]:
"""
Check for security headers.
Returns:
Tuple of (list of issues, security score 0-100)
"""
issues = []
score = 100
headers_lower = {k.lower(): v for k, v in headers.items()}
for header_name, config in SECURITY_HEADERS.items():
header_key = header_name.lower()
if header_key not in headers_lower:
# Missing header
severity = config['severity']
deduction = {'critical': 20, 'high': 15, 'medium': 10, 'low': 5, 'info': 2}
score -= deduction.get(severity, 5)
issues.append(IssueData(
category='headers',
severity=severity,
title=f'Missing security header: {header_name}',
description=config['description'],
tool='header_check',
remediation=config['remediation'],
raw_data={'header': header_name, 'status': 'missing'}
))
else:
# Header present, check value
value = headers_lower[header_key]
check_func = config.get('check_value')
if check_func and not check_func(value):
issues.append(IssueData(
category='headers',
severity='low',
title=f'Weak configuration: {header_name}',
description=(
f"{config['description']} "
f"Current value may not provide optimal protection: {value}"
),
tool='header_check',
remediation=config['remediation'],
raw_data={'header': header_name, 'value': value, 'status': 'weak'}
))
score -= 3
return issues, max(0, score)
def _check_cors(self, headers: Dict[str, str], url: str) -> List[IssueData]:
"""Check CORS configuration for issues."""
issues = []
headers_lower = {k.lower(): v for k, v in headers.items()}
acao = headers_lower.get('access-control-allow-origin', '')
acac = headers_lower.get('access-control-allow-credentials', '')
if acao == '*':
if acac.lower() == 'true':
# Critical: credentials with wildcard
check = CORS_CHECKS['credentials_with_wildcard']
issues.append(IssueData(
category='cors',
severity=check['severity'],
title=check['title'],
description=check['description'],
tool='header_check',
affected_url=url,
remediation=check['remediation'],
raw_data={
'Access-Control-Allow-Origin': acao,
'Access-Control-Allow-Credentials': acac
}
))
else:
# Warning: permissive origin
check = CORS_CHECKS['permissive_origin']
issues.append(IssueData(
category='cors',
severity='medium', # Lower severity without credentials
title=check['title'],
description=check['description'],
tool='header_check',
affected_url=url,
remediation=check['remediation'],
raw_data={'Access-Control-Allow-Origin': acao}
))
return issues
def _check_cookies(self, headers: Dict[str, str], url: str) -> List[IssueData]:
"""Check Set-Cookie headers for security flags."""
issues = []
headers_lower = {k.lower(): v for k, v in headers.items()}
# Get all Set-Cookie headers
set_cookies = []
for key, value in headers.items():
if key.lower() == 'set-cookie':
set_cookies.append(value)
is_https = url.startswith('https://')
for cookie in set_cookies:
cookie_lower = cookie.lower()
cookie_name = cookie.split('=')[0] if '=' in cookie else 'unknown'
cookie_issues = []
# Check Secure flag on HTTPS
if is_https and 'secure' not in cookie_lower:
cookie_issues.append({
'flag': 'Secure',
'description': (
'Cookie is set without Secure flag on HTTPS site. '
'This allows the cookie to be sent over unencrypted connections.'
),
'severity': 'high'
})
# Check HttpOnly flag (important for session cookies)
if 'httponly' not in cookie_lower:
# Check if it might be a session cookie
if any(term in cookie_name.lower() for term in ['session', 'auth', 'token', 'user']):
cookie_issues.append({
'flag': 'HttpOnly',
'description': (
'Session-like cookie is set without HttpOnly flag. '
'This allows JavaScript access, increasing XSS risk.'
),
'severity': 'high'
})
else:
cookie_issues.append({
'flag': 'HttpOnly',
'description': (
'Cookie is set without HttpOnly flag. '
'Consider adding it unless JavaScript needs access.'
),
'severity': 'low'
})
# Check SameSite attribute
if 'samesite' not in cookie_lower:
cookie_issues.append({
'flag': 'SameSite',
'description': (
'Cookie is set without SameSite attribute. '
'This can enable CSRF attacks in some scenarios.'
),
'severity': 'medium'
})
elif 'samesite=none' in cookie_lower and 'secure' not in cookie_lower:
cookie_issues.append({
'flag': 'SameSite=None without Secure',
'description': (
'Cookie has SameSite=None but no Secure flag. '
'Modern browsers will reject this cookie.'
),
'severity': 'medium'
})
# Create issues for this cookie
for ci in cookie_issues:
issues.append(IssueData(
category='security',
severity=ci['severity'],
title=f"Cookie '{cookie_name}' missing {ci['flag']} flag",
description=ci['description'],
tool='header_check',
affected_url=url,
remediation=(
f"Add the {ci['flag']} flag to the Set-Cookie header. "
f"Example: Set-Cookie: {cookie_name}=value; Secure; HttpOnly; SameSite=Strict"
),
raw_data={'cookie': cookie[:200]} # Truncate for storage
))
return issues