406 lines
15 KiB
Python
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
|