diff --git a/.github/workflows/php-lint.yml b/.github/workflows/php-lint.yml index 0e83136..804d020 100644 --- a/.github/workflows/php-lint.yml +++ b/.github/workflows/php-lint.yml @@ -10,6 +10,7 @@ on: - 'composer.json' - 'composer.lock' - 'phpstan.neon' + - 'phpunit.xml' - '.php-cs-fixer.dist.php' - '.github/workflows/php-lint.yml' pull_request: @@ -19,6 +20,7 @@ on: - 'composer.json' - 'composer.lock' - 'phpstan.neon' + - 'phpunit.xml' - '.php-cs-fixer.dist.php' - '.github/workflows/php-lint.yml' @@ -285,6 +287,58 @@ jobs: run: | composer audit --format=plain || echo "::warning::Vulnerabilities found in dependencies" + # ============================================================ + # Unit Tests - PHPUnit with coverage + # ============================================================ + tests: + name: Unit Tests (PHPUnit) + runs-on: ubuntu-latest + needs: syntax + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup PHP + uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2.32.0 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: xdebug + coverage: xdebug + tools: phpunit:10 + + - name: Install Composer dependencies + run: composer install --no-progress --prefer-dist --no-interaction + + - name: Run PHPUnit tests + run: | + vendor/bin/phpunit --coverage-text --coverage-clover=coverage/clover.xml + + - name: Check coverage threshold + run: | + # Extract coverage percentage from clover.xml + if [ -f coverage/clover.xml ]; then + COVERAGE=$(php -r " + \$xml = simplexml_load_file('coverage/clover.xml'); + \$metrics = \$xml->project->metrics; + \$elements = (int)\$metrics['elements']; + \$covered = (int)\$metrics['coveredelements']; + if (\$elements > 0) { + echo round((\$covered / \$elements) * 100, 2); + } else { + echo '0'; + } + ") + echo "Code coverage: ${COVERAGE}%" + echo "## Test Coverage: ${COVERAGE}%" >> $GITHUB_STEP_SUMMARY + + # Fail if coverage is below 70% + if (( $(echo "$COVERAGE < 70" | bc -l) )); then + echo "::warning::Coverage is below 70% threshold" + fi + fi + # ============================================================ # Multi-version PHP Test - Ensure compatibility # ============================================================ @@ -324,7 +378,7 @@ jobs: lint-summary: name: Lint Summary runs-on: ubuntu-latest - needs: [syntax, code-style, phpstan, license-headers, strict-types, security-patterns, composer-audit, php-compat] + needs: [syntax, code-style, phpstan, tests, license-headers, strict-types, security-patterns, composer-audit, php-compat] if: always() permissions: contents: read @@ -338,6 +392,7 @@ jobs: echo "| Syntax | ${{ needs.syntax.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY echo "| Code Style | ${{ needs.code-style.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY echo "| PHPStan | ${{ needs.phpstan.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Unit Tests | ${{ needs.tests.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY echo "| License Headers | ${{ needs.license-headers.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY echo "| Strict Types | ${{ needs.strict-types.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY echo "| Security Patterns | ${{ needs.security-patterns.result == 'success' && '✅ Pass' || '❌ Fail' }} |" >> $GITHUB_STEP_SUMMARY @@ -348,6 +403,7 @@ jobs: if [ "${{ needs.syntax.result }}" != "success" ] || \ [ "${{ needs.code-style.result }}" != "success" ] || \ [ "${{ needs.phpstan.result }}" != "success" ] || \ + [ "${{ needs.tests.result }}" != "success" ] || \ [ "${{ needs.strict-types.result }}" != "success" ] || \ [ "${{ needs.security-patterns.result }}" != "success" ]; then echo "" diff --git a/SECURE_DEFAULTS.md b/SECURE_DEFAULTS.md index ce85e4d..ac9d560 100644 --- a/SECURE_DEFAULTS.md +++ b/SECURE_DEFAULTS.md @@ -4,6 +4,7 @@ This document provides a comprehensive checklist for secure PHP development usin ## Table of Contents +- [OWASP Top 10 Mapping](#owasp-top-10-mapping) - [PHP Configuration](#php-configuration) - [Input Validation](#input-validation) - [Output Sanitization](#output-sanitization) @@ -18,6 +19,329 @@ This document provides a comprehensive checklist for secure PHP development usin --- +## OWASP Top 10 Mapping + +This section maps php-aegis features and checklist items to the [OWASP Top 10 2021](https://owasp.org/Top10/) vulnerabilities. + +### Summary Matrix + +| OWASP ID | Vulnerability | php-aegis Coverage | Section | +|----------|--------------|-------------------|---------| +| A01:2021 | Broken Access Control | Partial (Headers) | [HTTP Headers](#http-security-headers) | +| A02:2021 | Cryptographic Failures | Guidelines | [Cryptography](#cryptography) | +| A03:2021 | Injection | **Full** (Validator, Sanitizer, TurtleEscaper) | [Input](#input-validation), [Output](#output-sanitization) | +| A04:2021 | Insecure Design | Guidelines | [All Sections](#secure-defaults-checklist) | +| A05:2021 | Security Misconfiguration | **Full** (Headers) | [HTTP Headers](#http-security-headers), [PHP Config](#php-configuration) | +| A06:2021 | Vulnerable Components | Guidelines | [Dependencies](#dependency-management) | +| A07:2021 | Auth Failures | Guidelines | [Authentication](#authentication--sessions) | +| A08:2021 | Data Integrity Failures | Partial (CSP) | [HTTP Headers](#http-security-headers) | +| A09:2021 | Logging Failures | Guidelines | [Error Handling](#error-handling) | +| A10:2021 | SSRF | Partial (Validator) | [Input Validation](#input-validation) | + +--- + +### A01:2021 - Broken Access Control + +**Risk:** Attackers access unauthorized resources or perform actions outside their permissions. + +**php-aegis Mitigations:** + +| Control | php-aegis Feature | Code Example | +|---------|------------------|--------------| +| CSRF Prevention | `Headers::secure()` sets SameSite cookies | `Headers::secure()` | +| Clickjacking | `Headers::frameOptions('DENY')` | `Headers::frameOptions()` | +| CORS Policies | `Headers::crossOrigin*Policy()` | `Headers::crossOriginResourcePolicy()` | + +**Checklist:** +- [ ] Use `Headers::frameOptions('DENY')` to prevent clickjacking +- [ ] Implement proper session management (see [Authentication](#authentication--sessions)) +- [ ] Validate user permissions on every request +- [ ] Use CSRF tokens for state-changing operations +- [ ] Apply principle of least privilege + +--- + +### A02:2021 - Cryptographic Failures + +**Risk:** Sensitive data exposed due to weak/missing encryption. + +**php-aegis Mitigations:** + +| Control | php-aegis Feature | Code Example | +|---------|------------------|--------------| +| HTTPS Enforcement | `Validator::httpsUrl()` | `Validator::httpsUrl($url)` | +| HSTS | `Headers::strictTransportSecurity()` | `Headers::strictTransportSecurity(31536000, true, true)` | + +**Checklist:** +- [ ] Use `Validator::httpsUrl()` to reject non-HTTPS URLs +- [ ] Enable HSTS with `Headers::strictTransportSecurity()` +- [ ] Never use MD5/SHA1 for security (see [Cryptography](#cryptography)) +- [ ] Use `random_bytes()` for secure random data +- [ ] Use Argon2id for password hashing + +**CI Enforcement:** +```yaml +# In php-lint.yml - checks for weak cryptography +- name: Check weak cryptography + run: grep -rEn 'md5\s*\(|sha1\s*\(' --include="*.php" src/ +``` + +--- + +### A03:2021 - Injection + +**Risk:** Untrusted data interpreted as commands (SQL, XSS, OS, LDAP, Turtle). + +**php-aegis Mitigations:** + +| Attack Type | php-aegis Feature | Code Example | +|-------------|------------------|--------------| +| XSS (HTML) | `Sanitizer::html()` | `echo Sanitizer::html($input)` | +| XSS (Attr) | `Sanitizer::attr()` | `value=""` | +| XSS (JS) | `Sanitizer::js()` | `var x = ` | +| Path Traversal | `Validator::safeFilename()` | `Validator::safeFilename($name)` | +| Null Byte | `Validator::noNullBytes()` | `Validator::noNullBytes($path)` | +| RDF/SPARQL | `TurtleEscaper::string()` | `TurtleEscaper::literal($v)` | +| URL Injection | `Sanitizer::url()` | `href=""` | +| JSON Injection | `Sanitizer::json()` | `Sanitizer::json($data)` | + +**Checklist:** +- [ ] Use `Sanitizer::html()` for all HTML output +- [ ] Use `Sanitizer::attr()` for HTML attributes +- [ ] Use `Sanitizer::js()` for inline JavaScript +- [ ] Use `Sanitizer::json()` for JSON responses +- [ ] Use `TurtleEscaper::literal()` for RDF/Turtle data +- [ ] Use `Validator::safeFilename()` for file operations +- [ ] Use prepared statements for ALL database queries + +**CI Enforcement:** +```yaml +# In php-lint.yml - checks for injection patterns +- name: Check dangerous functions + run: | + grep -rEn 'eval\s*\(|exec\s*\(' --include="*.php" src/ + grep -rEn 'echo\s+\$_(GET|POST)' --include="*.php" src/ +``` + +--- + +### A04:2021 - Insecure Design + +**Risk:** Missing or ineffective security controls in application design. + +**php-aegis Mitigations:** + +| Control | php-aegis Feature | Purpose | +|---------|------------------|---------| +| Secure Defaults | `Headers::secure()` | One-call security setup | +| Type Safety | All methods require `string` types | Prevents type confusion | +| Fail Secure | Validators return `false` on invalid input | Reject by default | + +**Checklist:** +- [ ] Call `Headers::secure()` early in every request +- [ ] Use `declare(strict_types=1)` in all PHP files +- [ ] Validate before processing, sanitize before output +- [ ] Reject invalid input (don't try to "fix" it) +- [ ] Design with defense in depth + +--- + +### A05:2021 - Security Misconfiguration + +**Risk:** Missing security hardening, default credentials, verbose errors. + +**php-aegis Mitigations:** + +| Misconfiguration | php-aegis Feature | Code Example | +|-----------------|------------------|--------------| +| Missing CSP | `Headers::contentSecurityPolicy()` | `Headers::secure()` | +| Missing HSTS | `Headers::strictTransportSecurity()` | `Headers::secure()` | +| Server Leakage | `Headers::removeInsecureHeaders()` | `Headers::secure()` | +| MIME Sniffing | `Headers::contentTypeOptions()` | `Headers::secure()` | +| Missing Permissions-Policy | `Headers::permissionsPolicy()` | `Headers::secure()` | + +**Headers set by `Headers::secure()`:** +``` +Content-Security-Policy: default-src 'self' +Strict-Transport-Security: max-age=31536000; includeSubDomains +X-Frame-Options: DENY +X-Content-Type-Options: nosniff +X-XSS-Protection: 1; mode=block +Referrer-Policy: strict-origin-when-cross-origin +Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=() +``` + +**Checklist:** +- [ ] Call `Headers::secure()` on every response +- [ ] Configure PHP securely (see [PHP Configuration](#php-configuration)) +- [ ] Disable `display_errors` in production +- [ ] Remove default credentials and accounts +- [ ] Review all security headers with [securityheaders.com](https://securityheaders.com) + +--- + +### A06:2021 - Vulnerable and Outdated Components + +**Risk:** Using libraries with known vulnerabilities. + +**php-aegis Design:** +- **Zero runtime dependencies** - Only PHP 8.1+ built-ins +- No vulnerable dependencies to track in production + +**Checklist:** +- [ ] Run `composer audit` on every CI build +- [ ] Keep PHP version updated (8.1+ required) +- [ ] Review dev dependencies before adding +- [ ] Enable Dependabot/Renovate for automatic updates + +**CI Enforcement:** +```yaml +# In php-lint.yml +- name: Run Composer audit + run: composer audit --format=plain +``` + +--- + +### A07:2021 - Identification and Authentication Failures + +**Risk:** Weak passwords, session hijacking, credential stuffing. + +**php-aegis Mitigations:** + +| Control | php-aegis Feature | Purpose | +|---------|------------------|---------| +| Session Security | `Headers::secure()` sets cookie flags | SameSite, Secure | + +**Checklist:** +- [ ] Use `password_hash()` with `PASSWORD_ARGON2ID` +- [ ] Use `password_verify()` for constant-time comparison +- [ ] Regenerate session ID on login (`session_regenerate_id(true)`) +- [ ] Set session cookie flags: HttpOnly, Secure, SameSite=Strict +- [ ] Implement rate limiting for authentication +- [ ] Use MFA for sensitive operations + +--- + +### A08:2021 - Software and Data Integrity Failures + +**Risk:** Untrusted code execution, insecure CI/CD, missing integrity checks. + +**php-aegis Mitigations:** + +| Control | php-aegis Feature | Purpose | +|---------|------------------|---------| +| CSP | `Headers::contentSecurityPolicy()` | Prevents inline script injection | +| SRI Support | Design for external script verification | Subresource Integrity | + +**Checklist:** +- [ ] Use Content-Security-Policy to block inline scripts +- [ ] Pin GitHub Actions to commit SHAs (not tags) +- [ ] Verify `composer.lock` in CI builds +- [ ] Sign commits with GPG +- [ ] Use Subresource Integrity for CDN resources + +**CI Enforcement:** +```yaml +# Pin actions to SHA for integrity +- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 +``` + +--- + +### A09:2021 - Security Logging and Monitoring Failures + +**Risk:** Insufficient logging, missing alerting, undetected breaches. + +**Checklist:** +- [ ] Log authentication attempts (success and failure) +- [ ] Log access control failures +- [ ] Log input validation failures (potential attacks) +- [ ] Don't log sensitive data (passwords, tokens, PII) +- [ ] Set up alerting for anomalous patterns +- [ ] Monitor error logs for security issues + +**Error Handler Pattern:** +```php +set_exception_handler(function (Throwable $e): void { + // Log for operators + error_log(sprintf('[%s] %s', get_class($e), $e->getMessage())); + + // Generic response to users + http_response_code(500); + echo json_encode(['error' => 'An unexpected error occurred']); + exit(1); +}); +``` + +--- + +### A10:2021 - Server-Side Request Forgery (SSRF) + +**Risk:** Attacker forces server to make requests to unintended destinations. + +**php-aegis Mitigations:** + +| Control | php-aegis Feature | Code Example | +|---------|------------------|--------------| +| URL Validation | `Validator::url()` | `Validator::url($url)` | +| HTTPS Enforcement | `Validator::httpsUrl()` | `Validator::httpsUrl($url)` | +| Hostname Validation | `Validator::hostname()` | `Validator::hostname($host)` | +| IP Validation | `Validator::ip()`, `ipv4()`, `ipv6()` | `Validator::ip($ip)` | + +**Checklist:** +- [ ] Validate all user-supplied URLs with `Validator::url()` +- [ ] Prefer `Validator::httpsUrl()` to enforce HTTPS +- [ ] Maintain allowlist of permitted domains/IPs +- [ ] Block requests to internal/private IP ranges +- [ ] Don't follow redirects blindly + +**Safe URL Fetching:** +```php +use PhpAegis\Validator; + +function safeFetch(string $url): string { + // Validate URL format + if (!Validator::httpsUrl($url)) { + throw new InvalidArgumentException('Invalid or non-HTTPS URL'); + } + + // Parse and validate hostname + $host = parse_url($url, PHP_URL_HOST); + if (!$host || !Validator::domain($host)) { + throw new InvalidArgumentException('Invalid hostname'); + } + + // Block internal/private IPs (allowlist approach is better) + $ip = gethostbyname($host); + if (filter_var($ip, FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + throw new InvalidArgumentException('Private/reserved IP not allowed'); + } + + // Now safe to fetch + return file_get_contents($url); +} +``` + +--- + +### OWASP Coverage Summary + +| php-aegis Class | OWASP Categories Addressed | +|----------------|---------------------------| +| `Validator` | A03, A10 | +| `Sanitizer` | A03 | +| `Headers` | A01, A02, A04, A05, A08 | +| `TurtleEscaper` | A03 | + +**Legend:** +- **Full Coverage**: php-aegis provides direct protection +- **Partial Coverage**: php-aegis helps but additional measures needed +- **Guidelines**: Documentation and checklists provided + +--- + ## PHP Configuration ### Required Settings diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..43cbab4 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,42 @@ + + + + + + tests + + + + + + src + + + + + + + + + + + diff --git a/tests/HeadersTest.php b/tests/HeadersTest.php new file mode 100644 index 0000000..350ea49 --- /dev/null +++ b/tests/HeadersTest.php @@ -0,0 +1,537 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('X-Frame-Options must be DENY, SAMEORIGIN, or ALLOW-FROM uri'); + + Headers::frameOptions('INVALID'); + } + + // ========================================================================= + // Content-Type Options (OWASP A05 - Security Misconfiguration) + // ========================================================================= + + #[RunInSeparateProcess] + public function testContentTypeOptions(): void + { + Headers::contentTypeOptions(); + + $headers = xdebug_get_headers(); + self::assertContains('X-Content-Type-Options: nosniff', $headers); + } + + // ========================================================================= + // XSS Protection (OWASP A03 - Legacy Browser Protection) + // ========================================================================= + + #[RunInSeparateProcess] + public function testXssProtectionDefault(): void + { + Headers::xssProtection(); + + $headers = xdebug_get_headers(); + self::assertContains('X-XSS-Protection: 1; mode=block', $headers); + } + + #[RunInSeparateProcess] + public function testXssProtectionEnabled(): void + { + Headers::xssProtection(true, true); + + $headers = xdebug_get_headers(); + self::assertContains('X-XSS-Protection: 1; mode=block', $headers); + } + + #[RunInSeparateProcess] + public function testXssProtectionEnabledNoBlock(): void + { + Headers::xssProtection(true, false); + + $headers = xdebug_get_headers(); + self::assertContains('X-XSS-Protection: 1', $headers); + } + + #[RunInSeparateProcess] + public function testXssProtectionDisabled(): void + { + Headers::xssProtection(false); + + $headers = xdebug_get_headers(); + self::assertContains('X-XSS-Protection: 0', $headers); + } + + // ========================================================================= + // Referrer Policy (OWASP A05 - Security Misconfiguration) + // ========================================================================= + + #[RunInSeparateProcess] + public function testReferrerPolicyDefault(): void + { + Headers::referrerPolicy(); + + $headers = xdebug_get_headers(); + self::assertContains('Referrer-Policy: strict-origin-when-cross-origin', $headers); + } + + #[DataProvider('validReferrerPoliciesProvider')] + #[RunInSeparateProcess] + public function testReferrerPolicyValid(string $policy): void + { + Headers::referrerPolicy($policy); + + $headers = xdebug_get_headers(); + self::assertContains("Referrer-Policy: {$policy}", $headers); + } + + /** + * @return array + */ + public static function validReferrerPoliciesProvider(): array + { + return [ + 'no-referrer' => ['no-referrer'], + 'no-referrer-when-downgrade' => ['no-referrer-when-downgrade'], + 'origin' => ['origin'], + 'origin-when-cross-origin' => ['origin-when-cross-origin'], + 'same-origin' => ['same-origin'], + 'strict-origin' => ['strict-origin'], + 'strict-origin-when-cross-origin' => ['strict-origin-when-cross-origin'], + 'unsafe-url' => ['unsafe-url'], + ]; + } + + public function testReferrerPolicyRejectsInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid Referrer-Policy'); + + Headers::referrerPolicy('invalid-policy'); + } + + // ========================================================================= + // Strict Transport Security (OWASP A02 - Cryptographic Failures) + // ========================================================================= + + #[RunInSeparateProcess] + public function testStrictTransportSecurityDefault(): void + { + Headers::strictTransportSecurity(); + + $headers = xdebug_get_headers(); + self::assertContains('Strict-Transport-Security: max-age=31536000; includeSubDomains', $headers); + } + + #[RunInSeparateProcess] + public function testStrictTransportSecurityWithPreload(): void + { + Headers::strictTransportSecurity(31536000, true, true); + + $headers = xdebug_get_headers(); + self::assertContains( + 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload', + $headers + ); + } + + #[RunInSeparateProcess] + public function testStrictTransportSecurityNoSubdomains(): void + { + Headers::strictTransportSecurity(86400, false, false); + + $headers = xdebug_get_headers(); + self::assertContains('Strict-Transport-Security: max-age=86400', $headers); + } + + #[RunInSeparateProcess] + public function testStrictTransportSecurityCustomMaxAge(): void + { + Headers::strictTransportSecurity(3600); + + $headers = xdebug_get_headers(); + self::assertContains('Strict-Transport-Security: max-age=3600; includeSubDomains', $headers); + } + + // ========================================================================= + // Content Security Policy (OWASP A03, A05, A08) + // ========================================================================= + + #[RunInSeparateProcess] + public function testContentSecurityPolicySingleDirective(): void + { + Headers::contentSecurityPolicy([ + 'default-src' => ["'self'"], + ]); + + $headers = xdebug_get_headers(); + self::assertContains("Content-Security-Policy: default-src 'self'", $headers); + } + + #[RunInSeparateProcess] + public function testContentSecurityPolicyMultipleDirectives(): void + { + Headers::contentSecurityPolicy([ + 'default-src' => ["'self'"], + 'script-src' => ["'self'", 'https://cdn.example.com'], + 'style-src' => ["'self'", "'unsafe-inline'"], + ]); + + $headers = xdebug_get_headers(); + + // Find the CSP header + $cspHeader = null; + foreach ($headers as $header) { + if (str_starts_with($header, 'Content-Security-Policy:')) { + $cspHeader = $header; + break; + } + } + + self::assertNotNull($cspHeader); + self::assertStringContainsString("default-src 'self'", $cspHeader); + self::assertStringContainsString('script-src', $cspHeader); + self::assertStringContainsString('style-src', $cspHeader); + } + + #[RunInSeparateProcess] + public function testContentSecurityPolicyReportOnly(): void + { + Headers::contentSecurityPolicy([ + 'default-src' => ["'self'"], + ], true); + + $headers = xdebug_get_headers(); + + $found = false; + foreach ($headers as $header) { + if (str_starts_with($header, 'Content-Security-Policy-Report-Only:')) { + $found = true; + break; + } + } + + self::assertTrue($found, 'Expected Content-Security-Policy-Report-Only header'); + } + + #[RunInSeparateProcess] + public function testContentSecurityPolicyEmptyDirective(): void + { + Headers::contentSecurityPolicy([ + 'upgrade-insecure-requests' => [], + ]); + + $headers = xdebug_get_headers(); + self::assertContains('Content-Security-Policy: upgrade-insecure-requests', $headers); + } + + // ========================================================================= + // Permissions Policy (OWASP A05 - Security Misconfiguration) + // ========================================================================= + + #[RunInSeparateProcess] + public function testPermissionsPolicyEmpty(): void + { + Headers::permissionsPolicy([ + 'camera' => [], + 'microphone' => [], + ]); + + $headers = xdebug_get_headers(); + + $found = null; + foreach ($headers as $header) { + if (str_starts_with($header, 'Permissions-Policy:')) { + $found = $header; + break; + } + } + + self::assertNotNull($found); + self::assertStringContainsString('camera=()', $found); + self::assertStringContainsString('microphone=()', $found); + } + + #[RunInSeparateProcess] + public function testPermissionsPolicySelf(): void + { + Headers::permissionsPolicy([ + 'geolocation' => ['self'], + ]); + + $headers = xdebug_get_headers(); + self::assertContains('Permissions-Policy: geolocation=(self)', $headers); + } + + #[RunInSeparateProcess] + public function testPermissionsPolicyWithOrigins(): void + { + Headers::permissionsPolicy([ + 'camera' => ['self', 'https://example.com'], + ]); + + $headers = xdebug_get_headers(); + + $found = null; + foreach ($headers as $header) { + if (str_starts_with($header, 'Permissions-Policy:')) { + $found = $header; + break; + } + } + + self::assertNotNull($found); + self::assertStringContainsString('camera=(self "https://example.com")', $found); + } + + // ========================================================================= + // Cross-Origin Policies (OWASP A01 - Broken Access Control) + // ========================================================================= + + #[RunInSeparateProcess] + public function testCrossOriginEmbedderPolicyDefault(): void + { + Headers::crossOriginEmbedderPolicy(); + + $headers = xdebug_get_headers(); + self::assertContains('Cross-Origin-Embedder-Policy: require-corp', $headers); + } + + #[DataProvider('validCoepPoliciesProvider')] + #[RunInSeparateProcess] + public function testCrossOriginEmbedderPolicyValid(string $policy): void + { + Headers::crossOriginEmbedderPolicy($policy); + + $headers = xdebug_get_headers(); + self::assertContains("Cross-Origin-Embedder-Policy: {$policy}", $headers); + } + + /** + * @return array + */ + public static function validCoepPoliciesProvider(): array + { + return [ + 'require-corp' => ['require-corp'], + 'credentialless' => ['credentialless'], + 'unsafe-none' => ['unsafe-none'], + ]; + } + + public function testCrossOriginEmbedderPolicyRejectsInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + + Headers::crossOriginEmbedderPolicy('invalid'); + } + + #[RunInSeparateProcess] + public function testCrossOriginOpenerPolicyDefault(): void + { + Headers::crossOriginOpenerPolicy(); + + $headers = xdebug_get_headers(); + self::assertContains('Cross-Origin-Opener-Policy: same-origin', $headers); + } + + #[DataProvider('validCoopPoliciesProvider')] + #[RunInSeparateProcess] + public function testCrossOriginOpenerPolicyValid(string $policy): void + { + Headers::crossOriginOpenerPolicy($policy); + + $headers = xdebug_get_headers(); + self::assertContains("Cross-Origin-Opener-Policy: {$policy}", $headers); + } + + /** + * @return array + */ + public static function validCoopPoliciesProvider(): array + { + return [ + 'same-origin' => ['same-origin'], + 'same-origin-allow-popups' => ['same-origin-allow-popups'], + 'unsafe-none' => ['unsafe-none'], + ]; + } + + public function testCrossOriginOpenerPolicyRejectsInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + + Headers::crossOriginOpenerPolicy('invalid'); + } + + #[RunInSeparateProcess] + public function testCrossOriginResourcePolicyDefault(): void + { + Headers::crossOriginResourcePolicy(); + + $headers = xdebug_get_headers(); + self::assertContains('Cross-Origin-Resource-Policy: same-origin', $headers); + } + + #[DataProvider('validCorpPoliciesProvider')] + #[RunInSeparateProcess] + public function testCrossOriginResourcePolicyValid(string $policy): void + { + Headers::crossOriginResourcePolicy($policy); + + $headers = xdebug_get_headers(); + self::assertContains("Cross-Origin-Resource-Policy: {$policy}", $headers); + } + + /** + * @return array + */ + public static function validCorpPoliciesProvider(): array + { + return [ + 'same-origin' => ['same-origin'], + 'same-site' => ['same-site'], + 'cross-origin' => ['cross-origin'], + ]; + } + + public function testCrossOriginResourcePolicyRejectsInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + + Headers::crossOriginResourcePolicy('invalid'); + } + + // ========================================================================= + // Remove Insecure Headers (OWASP A05 - Security Misconfiguration) + // ========================================================================= + + #[RunInSeparateProcess] + public function testRemoveInsecureHeaders(): void + { + // Set headers that should be removed + header('X-Powered-By: PHP/8.3.0'); + header('Server: Apache/2.4.41'); + + Headers::removeInsecureHeaders(); + + $headers = xdebug_get_headers(); + + // These headers should be removed + foreach ($headers as $header) { + self::assertStringNotContainsString('X-Powered-By', $header); + self::assertStringNotContainsString('Server:', $header); + } + } + + // ========================================================================= + // Secure() - All-in-one (OWASP A05 - Secure Defaults) + // ========================================================================= + + #[RunInSeparateProcess] + public function testSecureSetsAllHeaders(): void + { + Headers::secure(); + + $headers = xdebug_get_headers(); + + // Check for essential security headers + $hasContentType = false; + $hasFrameOptions = false; + $hasXssProtection = false; + $hasReferrerPolicy = false; + $hasHsts = false; + $hasCsp = false; + $hasPermissions = false; + + foreach ($headers as $header) { + if (str_starts_with($header, 'X-Content-Type-Options:')) { + $hasContentType = true; + } + if (str_starts_with($header, 'X-Frame-Options:')) { + $hasFrameOptions = true; + } + if (str_starts_with($header, 'X-XSS-Protection:')) { + $hasXssProtection = true; + } + if (str_starts_with($header, 'Referrer-Policy:')) { + $hasReferrerPolicy = true; + } + if (str_starts_with($header, 'Strict-Transport-Security:')) { + $hasHsts = true; + } + if (str_starts_with($header, 'Content-Security-Policy:')) { + $hasCsp = true; + } + if (str_starts_with($header, 'Permissions-Policy:')) { + $hasPermissions = true; + } + } + + self::assertTrue($hasContentType, 'Missing X-Content-Type-Options'); + self::assertTrue($hasFrameOptions, 'Missing X-Frame-Options'); + self::assertTrue($hasXssProtection, 'Missing X-XSS-Protection'); + self::assertTrue($hasReferrerPolicy, 'Missing Referrer-Policy'); + self::assertTrue($hasHsts, 'Missing Strict-Transport-Security'); + self::assertTrue($hasCsp, 'Missing Content-Security-Policy'); + self::assertTrue($hasPermissions, 'Missing Permissions-Policy'); + } +} diff --git a/tests/SanitizerTest.php b/tests/SanitizerTest.php new file mode 100644 index 0000000..6d5c707 --- /dev/null +++ b/tests/SanitizerTest.php @@ -0,0 +1,407 @@ +')); + self::assertSame('&', Sanitizer::html('&')); + self::assertSame('"', Sanitizer::html('"')); + self::assertSame(''', Sanitizer::html("'")); + self::assertSame('<', Sanitizer::html('<')); + self::assertSame('>', Sanitizer::html('>')); + } + + #[DataProvider('xssVectorsProvider')] + public function testHtmlPreventsXss(string $attack, string $context): void + { + $sanitized = Sanitizer::html($attack); + + // The sanitized output should not contain unescaped dangerous characters + self::assertStringNotContainsString(' ['', 'script tag'], + 'img onerror' => ['', 'event handler'], + 'svg onload' => ['', 'SVG event'], + 'body onload' => ['', 'body event'], + 'iframe' => ['