diff --git a/HANDOVER_SANCTIFY.md b/HANDOVER_SANCTIFY.md new file mode 100644 index 0000000..e840027 --- /dev/null +++ b/HANDOVER_SANCTIFY.md @@ -0,0 +1,244 @@ +# Handover Document: sanctify-php Integration Insights + +## Context + +This document summarizes findings from integrating `php-aegis` and `sanctify-php` into a WordPress semantic theme (wp-sinople-theme). It provides actionable recommendations for the `sanctify-php` team based on real-world usage patterns. + +## Role Clarification + +| Tool | Role | When Used | +|------|------|-----------| +| **php-aegis** | Runtime security library | During request handling (validation, sanitization, headers) | +| **sanctify-php** | Static analysis tool | During development/CI (find vulnerabilities before deploy) | + +These are **complementary**, not competing tools: +- `sanctify-php` finds the bugs +- `php-aegis` provides the fixes + +## Issues Discovered During Integration + +### 1. Haskell Toolchain Dependency + +**Problem**: `sanctify-php` requires GHC/Cabal to build, which is a significant barrier for PHP developers. + +**Impact**: Most PHP teams don't have Haskell expertise or toolchain installed. + +**Recommendations**: +- Provide pre-built binaries for Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), Windows +- Create official Docker image: `ghcr.io/hyperpolymath/sanctify-php:latest` +- Consider GitHub Actions integration that runs analysis without local install +- Add installation via common package managers (Homebrew, apt, nix) + +**Example Docker usage**: +```bash +docker run --rm -v $(pwd):/workspace ghcr.io/hyperpolymath/sanctify-php analyze /workspace +``` + +### 2. PHP 8.x Syntax Support + +**Problem**: Parser may not handle all PHP 8.x syntax (enums, union types, named arguments, attributes, match expressions, constructor property promotion). + +**Test cases needed**: +```php +// Enums (PHP 8.1+) +enum Status: string { + case Draft = 'draft'; + case Published = 'published'; +} + +// Union types (PHP 8.0+) +function process(string|int $input): string|false { ... } + +// Attributes (PHP 8.0+) +#[Route('/api/users')] +class UserController { ... } + +// Constructor property promotion (PHP 8.0+) +class User { + public function __construct( + public readonly string $name, + private int $age = 0, + ) {} +} + +// Named arguments (PHP 8.0+) +htmlspecialchars(string: $input, flags: ENT_QUOTES); + +// Match expressions (PHP 8.0+) +$result = match($status) { + Status::Draft => 'Editing', + Status::Published => 'Live', +}; +``` + +**Recommendation**: Add PHP 8.x grammar rules and comprehensive test suite. + +### 3. RDF/Turtle Output Context Awareness + +**Problem**: Static analyzer doesn't detect RDF/Turtle injection vulnerabilities in semantic web themes. + +**Background**: Semantic WordPress themes output RDF Turtle format for linked data. Standard XSS detection won't catch Turtle-specific injection vectors. + +**Vulnerable pattern** (not currently detected): +```php +// DANGEROUS: addslashes() is insufficient for Turtle +$turtle = '<' . $uri . '> rdfs:label "' . addslashes($label) . '" .'; +``` + +**Attack vectors**: +```turtle +# Turtle escape sequences +\n \r \t \\ \" \uXXXX \UXXXXXXXX + +# IRI injection + owl:sameAs +``` + +**Recommendation**: Add detection rules for: +- `addslashes()` used in RDF/Turtle context +- Unescaped variables in Turtle string literals (`"..."`) +- Unescaped IRIs (`<...>`) +- Missing use of proper escaping functions + +**Suggested rule signatures**: +``` +turtle_string_injection: Detects unescaped user input in Turtle string literals +turtle_iri_injection: Detects unescaped user input in Turtle IRIs +rdf_semantic_injection: Detects potential semantic attacks via RDF +``` + +### 4. WordPress Integration Documentation + +**Problem**: No clear guidance for WordPress-specific vulnerability patterns. + +**WordPress-specific patterns to detect**: + +```php +// DANGEROUS: Direct $_GET/$_POST usage +echo $_GET['query']; // XSS + +// DANGEROUS: Missing nonce verification +if (isset($_POST['action'])) { ... } // CSRF + +// DANGEROUS: Direct SQL interpolation +$wpdb->query("SELECT * FROM users WHERE id = " . $_GET['id']); // SQLi + +// DANGEROUS: Unescaped output +echo $user_input; // Should use esc_html(), esc_attr(), etc. + +// DANGEROUS: Privileged action without capability check +add_action('wp_ajax_delete_user', 'delete_user_handler'); +function delete_user_handler() { + // Missing: current_user_can('delete_users') + wp_delete_user($_POST['user_id']); +} +``` + +**WordPress-specific safe patterns**: +```php +// Safe escaping functions +esc_html($text) +esc_attr($attr) +esc_url($url) +wp_kses($html, $allowed) +wp_kses_post($html) + +// Safe nonce verification +wp_verify_nonce($_POST['_wpnonce'], 'action_name') +check_admin_referer('action_name') + +// Safe capability checks +current_user_can('edit_posts') +``` + +**Recommendation**: Create WordPress-specific ruleset that: +- Detects missing `esc_*` function usage +- Detects missing nonce verification in form handlers +- Detects missing capability checks in AJAX handlers +- Recognizes WordPress sanitization functions as safe sinks + +### 5. IndieWeb/Micropub Pattern Detection + +**Problem**: No awareness of IndieWeb protocols (Micropub, IndieAuth, Webmention). + +**Patterns to detect**: + +```php +// DANGEROUS: Missing IndieAuth token verification +function handle_micropub($request) { + $content = $request['content']; // Unverified! + create_post($content); +} + +// DANGEROUS: Webmention SSRF +function verify_webmention($source) { + $response = wp_remote_get($source); // Can hit internal IPs +} + +// DANGEROUS: Micropub content injection +$mf2 = Mf2\parse($html, $source); +$content = $mf2['items'][0]['properties']['content'][0]; +echo $content; // Unsanitized from external source +``` + +**Recommendation**: Add rules for common IndieWeb vulnerability patterns. + +## Integration Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Development Workflow │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Developer │───▶│ sanctify-php │───▶│ Fix Code │ │ +│ │ Writes Code │ │ (Analysis) │ │ (Guidance) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ php-aegis │ │ +│ │ (Runtime) │ │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Recommended sanctify-php Output Format + +When `sanctify-php` detects a vulnerability, it should suggest the `php-aegis` fix: + +``` +VULNERABILITY: XSS in output context +FILE: theme/template.php:42 +CODE: echo $user_input; + +RECOMMENDATION: + Use php-aegis Sanitizer for proper encoding: + + Before: echo $user_input; + After: echo \PhpAegis\Sanitizer::html($user_input); + + Install: composer require hyperpolymath/php-aegis +``` + +## Priority Recommendations Summary + +| Priority | Issue | Effort | +|----------|-------|--------| +| P0 | Pre-built binaries / Docker image | Medium | +| P0 | PHP 8.x syntax support | High | +| P1 | WordPress-specific rulesets | Medium | +| P1 | RDF/Turtle context detection | Medium | +| P2 | IndieWeb protocol patterns | Low | +| P2 | php-aegis fix suggestions in output | Low | + +## Contact + +For questions about this integration or to coordinate between repos: +- php-aegis: https://github.com/hyperpolymath/php-aegis +- Integration tested in: wp-sinople-theme + +--- + +*Generated from real-world WordPress semantic theme integration experience.* diff --git a/README.adoc b/README.adoc index 501bf71..eee366c 100644 --- a/README.adoc +++ b/README.adoc @@ -16,8 +16,10 @@ php-aegis provides a collection of security-focused utilities for PHP applicatio === Key Features -* **Input Validation** - Strict validation for emails, URLs, and common data formats -* **XSS Prevention** - HTML sanitization with proper encoding +* **Input Validation** - Strict validation for emails, URLs, IPs, UUIDs, and more +* **Context-Aware Sanitization** - HTML, JS, CSS, URL, and JSON output escaping +* **Security Headers** - Easy CSP, HSTS, X-Frame-Options, and more +* **RDF/Turtle Escaping** - Unique W3C-compliant semantic web security (no other PHP lib does this) * **Type Safety** - Full `strict_types` enforcement throughout * **Modern PHP** - Requires PHP 8.1+ for latest security features * **Zero Dependencies** - Core library has no external dependencies @@ -58,90 +60,169 @@ declare(strict_types=1); use PhpAegis\Validator; use PhpAegis\Sanitizer; +use PhpAegis\Headers; -$validator = new Validator(); -$sanitizer = new Sanitizer(); +// Apply security headers (call before any output) +Headers::secure(); // Validate user input $email = $_POST['email'] ?? ''; -if (!$validator->email($email)) { +if (!Validator::email($email)) { throw new InvalidArgumentException('Invalid email address'); } // Sanitize for HTML output $userContent = $_POST['comment'] ?? ''; -$safeHtml = $sanitizer->html($userContent); +$safeHtml = Sanitizer::html($userContent); echo "

{$safeHtml}

"; ---- +=== Semantic Web (RDF/Turtle) + +[source,php] +---- +'; + +// Complete triple +echo TurtleEscaper::triple( + 'https://example.org/resource/1', + 'http://www.w3.org/2000/01/rdf-schema#label', + $userLabel, + 'en' +); +---- + == API Reference === Validator -The `Validator` class provides strict input validation methods. - -==== `email(string $email): bool` +The `Validator` class provides strict input validation methods (all static). -Validates email addresses using PHP's `FILTER_VALIDATE_EMAIL`. +==== Core Validators [source,php] ---- -$validator = new Validator(); - -$validator->email('user@example.com'); // true -$validator->email('invalid'); // false -$validator->email(''); // false +Validator::email('user@example.com'); // true +Validator::url('https://example.com'); // true +Validator::httpsUrl('http://insecure'); // false (requires HTTPS) ---- -==== `url(string $url): bool` - -Validates URLs using PHP's `FILTER_VALIDATE_URL`. +==== Network Validators [source,php] ---- -$validator = new Validator(); +Validator::ip('192.168.1.1'); // true (v4 or v6) +Validator::ipv4('192.168.1.1'); // true +Validator::ipv6('::1'); // true +---- -$validator->url('https://example.com'); // true -$validator->url('ftp://files.example.com'); // true -$validator->url('not-a-url'); // false +==== Format Validators + +[source,php] +---- +Validator::uuid('550e8400-e29b-41d4-a716-446655440000'); // true +Validator::slug('my-post-title'); // true +Validator::json('{"valid": true}'); // true ---- -=== Sanitizer +==== Security Validators -The `Sanitizer` class provides input sanitization for safe output. +[source,php] +---- +Validator::noNullBytes("safe\x00string"); // false (null byte!) +Validator::safeFilename('../../../etc/passwd'); // false +Validator::safeFilename('document.pdf'); // true +---- -==== `html(string $input): string` +=== Sanitizer -Sanitizes strings for safe HTML output, preventing XSS attacks. +The `Sanitizer` class provides context-aware output escaping (all static). -* Encodes `<`, `>`, `&`, `"`, `'` -* Uses UTF-8 encoding -* HTML5 compliant +==== HTML Context [source,php] ---- -$sanitizer = new Sanitizer(); - -$sanitizer->html(''); +Sanitizer::html(''); // Returns: <script>alert("xss")</script> -$sanitizer->html("It's safe & secure"); -// Returns: It's safe & secure +Sanitizer::stripTags('

Hello World

'); +// Returns: Hello World +---- + +==== Other Contexts + +[source,php] +---- +Sanitizer::js("user's input"); // Safe for JS strings +Sanitizer::css("malicious;color:red"); // Safe for CSS +Sanitizer::url("path with spaces"); // URL encoded +Sanitizer::json(['key' => 'value']); // Safe JSON +Sanitizer::filename("../../../etc/passwd"); // Returns: etc_passwd ---- -==== `stripTags(string $input): string` +=== Headers -Removes all HTML and PHP tags from input. +Security headers helper (call before output). [source,php] ---- -$sanitizer = new Sanitizer(); +// Apply all recommended headers at once +Headers::secure(); + +// Or configure individually +Headers::contentSecurityPolicy([ + 'default-src' => ["'self'"], + 'script-src' => ["'self'", 'https://cdn.example.com'], + 'style-src' => ["'self'", "'unsafe-inline'"], +]); +Headers::strictTransportSecurity(maxAge: 31536000, preload: true); +Headers::frameOptions('SAMEORIGIN'); +Headers::referrerPolicy('strict-origin-when-cross-origin'); +Headers::permissionsPolicy([ + 'geolocation' => [], + 'camera' => [], +]); +Headers::removeInsecureHeaders(); // Removes X-Powered-By, Server +---- -$sanitizer->stripTags('

Hello World

'); -// Returns: Hello World +=== TurtleEscaper + +W3C-compliant RDF Turtle escaping for semantic web applications. -$sanitizer->stripTags(''); -// Returns: (empty string) +[source,php] +---- +// Escape string literals +TurtleEscaper::string('Hello "World"'); +// Returns: Hello \"World\" + +// Escape IRIs +TurtleEscaper::iri('https://example.org/resource#1'); + +// Build complete literals with language/datatype +TurtleEscaper::literal('Bonjour', language: 'fr'); +// Returns: "Bonjour"@fr + +TurtleEscaper::literal('42', datatype: 'http://www.w3.org/2001/XMLSchema#integer'); +// Returns: "42"^^xsd:integer + +// Build complete triples +TurtleEscaper::triple( + 'https://example.org/person/1', + 'http://xmlns.com/foaf/0.1/name', + 'Alice', + 'en' +); +// Returns: "Alice"@en . ---- == Security Considerations @@ -206,34 +287,34 @@ vendor/bin/php-cs-fixer fix --dry-run == Roadmap -Planned features for future releases: +See link:ROADMAP_PRIORITY.md[ROADMAP_PRIORITY.md] for the detailed, integration-informed roadmap. -=== v0.2.0 - Extended Validators -* [ ] `Validator::ip()` - IPv4/IPv6 validation -* [ ] `Validator::uuid()` - UUID format validation -* [ ] `Validator::slug()` - URL slug validation -* [ ] `Validator::phone()` - Phone number validation +=== Recently Completed (v0.1.1) -=== v0.3.0 - Security Headers -* [ ] `Headers::csp()` - Content Security Policy helper -* [ ] `Headers::hsts()` - HSTS header helper -* [ ] `Headers::noSniff()` - X-Content-Type-Options -* [ ] `Headers::frameOptions()` - X-Frame-Options +* [x] Extended validators (IP, UUID, slug, JSON, filename safety) +* [x] Context-aware sanitizers (JS, CSS, URL, JSON, filename) +* [x] Security headers module (CSP, HSTS, X-Frame-Options, etc.) +* [x] RDF/Turtle escaping (unique differentiator) +* [x] Static methods (no instance required) +* [x] SPDX license headers -=== v0.4.0 - Rate Limiting -* [ ] `RateLimiter` - Token bucket implementation -* [ ] Redis/APCu backend support +=== Next Up -=== Future +* [ ] IndieWeb security helpers (Micropub, IndieAuth, Webmention) +* [ ] Rate limiting with file/memory backends * [ ] CSRF token generation and validation * [ ] Input filtering chains -* [ ] Audit logging utilities == Related Projects +* https://github.com/hyperpolymath/sanctify-php[sanctify-php] - Static analysis for PHP security (complementary tool) * https://github.com/hyperpolymath/wp-audit-toolkit[wp-audit-toolkit] - WordPress security auditing * https://github.com/hyperpolymath/proof-of-work[proof-of-work] - Proof-of-work spam prevention +=== Integration Notes + +For teams using both `php-aegis` and `sanctify-php`, see link:HANDOVER_SANCTIFY.md[HANDOVER_SANCTIFY.md] for integration guidance and coordinated workflows. + == License MIT License - See link:LICENSE.txt[LICENSE.txt] for details. diff --git a/ROADMAP_PRIORITY.md b/ROADMAP_PRIORITY.md new file mode 100644 index 0000000..f3b790a --- /dev/null +++ b/ROADMAP_PRIORITY.md @@ -0,0 +1,230 @@ +# php-aegis Roadmap (Integration-Informed Priority) + +This roadmap is prioritized based on real-world integration experience with WordPress semantic themes and the feedback received during the wp-sinople-theme security integration. + +## Context: Why This Matters + +During integration testing, the following gaps were identified: + +1. **Feature set too minimal** - WordPress has `esc_html()`, `esc_attr()`, etc. already +2. **No RDF/Turtle support** - Semantic themes need specialized escaping +3. **Missing SPDX headers** - Compliance requirement not met +4. **Not leveraging PHP 8.1+** - Enums, union types, readonly properties unused + +This roadmap addresses these gaps in priority order. + +--- + +## Phase 1: Foundation Fixes (v0.1.1) + +**Goal**: Address compliance and differentiation issues immediately. + +### 1.1 SPDX License Headers ✅ +- Add `SPDX-License-Identifier: MIT OR AGPL-3.0-or-later` to all PHP files +- Add `SPDX-FileCopyrightText` with year and author + +### 1.2 Static Methods +- Convert `Validator` and `Sanitizer` to use static methods +- Rationale: No instance state needed, improves ergonomics +- Before: `(new Sanitizer())->html($input)` +- After: `Sanitizer::html($input)` + +### 1.3 RDF/Turtle Escaping Module ✅ +- `TurtleEscaper::string(string $input): string` - Escape for Turtle string literals +- `TurtleEscaper::iri(string $uri): string` - Escape/validate for Turtle IRIs +- This is a **unique differentiator** - no other PHP library does this properly + +--- + +## Phase 2: Security Headers (v0.2.0) + +**Goal**: Provide value beyond WordPress built-ins. + +### 2.1 Headers Class +```php +Headers::contentSecurityPolicy(array $directives): void +Headers::strictTransportSecurity(int $maxAge, bool $subdomains = true): void +Headers::xFrameOptions(string $value = 'DENY'): void +Headers::xContentTypeOptions(): void // nosniff +Headers::referrerPolicy(string $policy = 'strict-origin-when-cross-origin'): void +Headers::permissionsPolicy(array $permissions): void +``` + +### 2.2 All-in-One Security Headers +```php +Headers::secure(): void // Apply sensible defaults for all headers +``` + +### Why This Matters +- WordPress doesn't provide header helpers +- Frameworks often require manual configuration +- This provides "secure by default" with one function call + +--- + +## Phase 3: Extended Validators (v0.3.0) + +**Goal**: Cover common validation needs with strict, type-safe implementations. + +### 3.1 Network Validators +```php +Validator::ip(string $ip): bool // IPv4 or IPv6 +Validator::ipv4(string $ip): bool +Validator::ipv6(string $ip): bool +Validator::cidr(string $cidr): bool +Validator::hostname(string $host): bool +Validator::domain(string $domain): bool +``` + +### 3.2 Format Validators +```php +Validator::uuid(string $uuid): bool // RFC 4122 +Validator::slug(string $slug): bool // URL-safe slugs +Validator::semver(string $version): bool // Semantic versioning +Validator::iso8601(string $date): bool // ISO 8601 datetime +Validator::json(string $json): bool // Valid JSON +``` + +### 3.3 Security Validators +```php +Validator::noNullBytes(string $input): bool +Validator::printable(string $input): bool +Validator::safeFilename(string $filename): bool // No path traversal +Validator::httpsUrl(string $url): bool // Enforce HTTPS +``` + +--- + +## Phase 4: Context-Aware Sanitization (v0.4.0) + +**Goal**: Provide correct escaping for every output context. + +### 4.1 Context Enum (PHP 8.1+) +```php +enum OutputContext: string { + case Html = 'html'; + case HtmlAttribute = 'attr'; + case JavaScript = 'js'; + case Css = 'css'; + case Url = 'url'; + case Sql = 'sql'; // For display only, not query building + case Json = 'json'; + case Turtle = 'turtle'; // RDF Turtle + case NTriples = 'ntriples'; // RDF N-Triples +} +``` + +### 4.2 Unified Escape Method +```php +Sanitizer::escape(string $input, OutputContext $context): string +``` + +### 4.3 Specialized Methods +```php +Sanitizer::jsString(string $input): string // Safe for JS string literals +Sanitizer::cssString(string $input): string // Safe for CSS values +Sanitizer::urlEncode(string $input): string // Proper URL encoding +Sanitizer::jsonEncode(mixed $input): string // Safe JSON with flags +``` + +--- + +## Phase 5: IndieWeb Security (v0.5.0) + +**Goal**: First-class support for IndieWeb/semantic web patterns. + +### 5.1 Micropub Content Sanitizer +```php +Micropub::sanitizeContent(string $html, array $allowedTags = []): string +Micropub::validateEntry(array $mf2): ValidationResult +``` + +### 5.2 IndieAuth Helpers +```php +IndieAuth::verifyToken(string $token, string $endpoint): TokenResult +IndieAuth::validateMe(string $url): bool // Valid "me" URL +IndieAuth::validateRedirectUri(string $uri, string $clientId): bool +``` + +### 5.3 Webmention Validators +```php +Webmention::validateSource(string $url): bool // Not internal IP +Webmention::validateTarget(string $url, string $domain): bool +``` + +--- + +## Phase 6: Rate Limiting (v0.6.0) + +**Goal**: Protect against abuse without external dependencies. + +### 6.1 Token Bucket Implementation +```php +interface RateLimitStore { + public function get(string $key): ?TokenBucket; + public function set(string $key, TokenBucket $bucket, int $ttl): void; +} + +class MemoryStore implements RateLimitStore { ... } +class FileStore implements RateLimitStore { ... } +class RedisStore implements RateLimitStore { ... } // Optional +class ApcuStore implements RateLimitStore { ... } // Optional +``` + +### 6.2 Rate Limiter +```php +$limiter = new RateLimiter( + store: new FileStore('/tmp/ratelimit'), + capacity: 100, // requests + refillRate: 10, // per second +); + +if (!$limiter->attempt($clientIp)) { + http_response_code(429); + exit; +} +``` + +--- + +## Differentiation Strategy + +### What WordPress Has (don't duplicate) +- `esc_html()`, `esc_attr()`, `esc_url()`, `esc_js()` +- `wp_kses()`, `wp_kses_post()` +- `sanitize_*()` functions +- Nonce verification + +### What php-aegis Provides (unique value) +| Feature | WordPress | Laravel | Symfony | php-aegis | +|---------|-----------|---------|---------|-----------| +| RDF/Turtle escaping | ❌ | ❌ | ❌ | ✅ | +| Security headers helper | ❌ | Partial | Partial | ✅ | +| IndieWeb validation | ❌ | ❌ | ❌ | ✅ | +| Zero dependencies | N/A | ❌ | ❌ | ✅ | +| PHP 8.1+ strict types | ❌ | ❌ | ❌ | ✅ | +| Rate limiting (no Redis) | ❌ | ❌ | ❌ | ✅ | + +--- + +## Success Metrics + +1. **Adoption**: Downloads on Packagist +2. **Integration**: Used in WordPress themes, Laravel packages +3. **Coverage**: CVE fixes attributable to php-aegis usage +4. **Community**: GitHub stars, issues, PRs + +--- + +## Timeline Philosophy + +Per project guidelines, no time estimates are provided. Work proceeds based on: +1. User demand (GitHub issues) +2. Security criticality +3. Contributor availability + +Phases can be reordered based on community feedback. + +--- + +*This roadmap reflects lessons learned from real WordPress integration.* diff --git a/src/Headers.php b/src/Headers.php new file mode 100644 index 0000000..1fc6816 --- /dev/null +++ b/src/Headers.php @@ -0,0 +1,251 @@ + ["'self'"], + ]); + } + + /** + * Set Content-Security-Policy header. + * + * @param array> $directives CSP directives + * @param bool $reportOnly If true, use Content-Security-Policy-Report-Only + */ + public static function contentSecurityPolicy(array $directives, bool $reportOnly = false): void + { + $parts = []; + + foreach ($directives as $directive => $values) { + if (empty($values)) { + $parts[] = $directive; + } else { + $parts[] = $directive . ' ' . implode(' ', $values); + } + } + + $headerName = $reportOnly + ? 'Content-Security-Policy-Report-Only' + : 'Content-Security-Policy'; + + header($headerName . ': ' . implode('; ', $parts)); + } + + /** + * Set Strict-Transport-Security header (HSTS). + * + * @param int $maxAge Max age in seconds (default: 1 year) + * @param bool $includeSubDomains Include subdomains + * @param bool $preload Include in HSTS preload list + */ + public static function strictTransportSecurity( + int $maxAge = 31536000, + bool $includeSubDomains = true, + bool $preload = false + ): void { + $value = 'max-age=' . $maxAge; + + if ($includeSubDomains) { + $value .= '; includeSubDomains'; + } + + if ($preload) { + $value .= '; preload'; + } + + header('Strict-Transport-Security: ' . $value); + } + + /** + * Set X-Frame-Options header to prevent clickjacking. + * + * @param string $value DENY, SAMEORIGIN, or ALLOW-FROM uri + */ + public static function frameOptions(string $value = 'DENY'): void + { + $allowed = ['DENY', 'SAMEORIGIN']; + + if (!in_array($value, $allowed, true) && !str_starts_with($value, 'ALLOW-FROM ')) { + throw new \InvalidArgumentException( + 'X-Frame-Options must be DENY, SAMEORIGIN, or ALLOW-FROM uri' + ); + } + + header('X-Frame-Options: ' . $value); + } + + /** + * Set X-Content-Type-Options to prevent MIME sniffing. + */ + public static function contentTypeOptions(): void + { + header('X-Content-Type-Options: nosniff'); + } + + /** + * Set X-XSS-Protection header. + * + * Note: This header is deprecated in modern browsers but still useful + * for legacy browser support. + * + * @param bool $enable Enable XSS filter + * @param bool $block Block page instead of sanitizing + */ + public static function xssProtection(bool $enable = true, bool $block = true): void + { + if (!$enable) { + header('X-XSS-Protection: 0'); + return; + } + + $value = '1'; + if ($block) { + $value .= '; mode=block'; + } + + header('X-XSS-Protection: ' . $value); + } + + /** + * Set Referrer-Policy header. + * + * @param string $policy Referrer policy value + */ + public static function referrerPolicy(string $policy = 'strict-origin-when-cross-origin'): void + { + $allowed = [ + 'no-referrer', + 'no-referrer-when-downgrade', + 'origin', + 'origin-when-cross-origin', + 'same-origin', + 'strict-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url', + ]; + + if (!in_array($policy, $allowed, true)) { + throw new \InvalidArgumentException( + 'Invalid Referrer-Policy: ' . $policy + ); + } + + header('Referrer-Policy: ' . $policy); + } + + /** + * Set Permissions-Policy header (replaces Feature-Policy). + * + * @param array> $permissions Feature permissions + */ + public static function permissionsPolicy(array $permissions): void + { + $parts = []; + + foreach ($permissions as $feature => $allowlist) { + if (empty($allowlist)) { + $parts[] = $feature . '=()'; + } else { + $quoted = array_map( + static fn(string $v): string => $v === 'self' ? 'self' : '"' . $v . '"', + $allowlist + ); + $parts[] = $feature . '=(' . implode(' ', $quoted) . ')'; + } + } + + header('Permissions-Policy: ' . implode(', ', $parts)); + } + + /** + * Set Cross-Origin-Embedder-Policy header. + * + * @param string $policy require-corp, credentialless, or unsafe-none + */ + public static function crossOriginEmbedderPolicy(string $policy = 'require-corp'): void + { + $allowed = ['require-corp', 'credentialless', 'unsafe-none']; + + if (!in_array($policy, $allowed, true)) { + throw new \InvalidArgumentException( + 'Invalid Cross-Origin-Embedder-Policy: ' . $policy + ); + } + + header('Cross-Origin-Embedder-Policy: ' . $policy); + } + + /** + * Set Cross-Origin-Opener-Policy header. + * + * @param string $policy same-origin, same-origin-allow-popups, or unsafe-none + */ + public static function crossOriginOpenerPolicy(string $policy = 'same-origin'): void + { + $allowed = ['same-origin', 'same-origin-allow-popups', 'unsafe-none']; + + if (!in_array($policy, $allowed, true)) { + throw new \InvalidArgumentException( + 'Invalid Cross-Origin-Opener-Policy: ' . $policy + ); + } + + header('Cross-Origin-Opener-Policy: ' . $policy); + } + + /** + * Set Cross-Origin-Resource-Policy header. + * + * @param string $policy same-origin, same-site, or cross-origin + */ + public static function crossOriginResourcePolicy(string $policy = 'same-origin'): void + { + $allowed = ['same-origin', 'same-site', 'cross-origin']; + + if (!in_array($policy, $allowed, true)) { + throw new \InvalidArgumentException( + 'Invalid Cross-Origin-Resource-Policy: ' . $policy + ); + } + + header('Cross-Origin-Resource-Policy: ' . $policy); + } + + /** + * Remove potentially dangerous headers that leak information. + */ + public static function removeInsecureHeaders(): void + { + header_remove('X-Powered-By'); + header_remove('Server'); + } +} diff --git a/src/Sanitizer.php b/src/Sanitizer.php index 488ce0e..f74fa19 100644 --- a/src/Sanitizer.php +++ b/src/Sanitizer.php @@ -1,27 +1,110 @@ '\\\\', // Backslash must be first + '"' => '\\"', // Double quote + "'" => "\\'", // Single quote + "\t" => '\\t', // Tab + "\b" => '\\b', // Backspace + "\n" => '\\n', // Newline + "\r" => '\\r', // Carriage return + "\f" => '\\f', // Form feed + ]; + + $escaped = str_replace( + array_keys($replacements), + array_values($replacements), + $input + ); + + // Escape any other control characters using \uXXXX + $escaped = preg_replace_callback( + '/[\x00-\x08\x0B\x0E-\x1F\x7F]/', + static fn(array $matches): string => sprintf('\\u%04X', ord($matches[0])), + $escaped + ) ?? $escaped; + + return $escaped; + } + + /** + * Escape and validate an IRI for use in Turtle <...> notation. + * + * @param string $uri The URI/IRI to escape + * @return string Safe for use in Turtle <...> IRI references + * @throws \InvalidArgumentException If the URI is malformed + */ + public static function iri(string $uri): string + { + // Basic validation - must be a valid URL structure + if (!filter_var($uri, FILTER_VALIDATE_URL)) { + // Allow relative IRIs, but validate they don't contain dangerous characters + if (preg_match('/[<>"{}|\\\\^`\x00-\x20]/', $uri)) { + throw new \InvalidArgumentException('Invalid IRI: contains disallowed characters'); + } + } + + // Characters that must be escaped in IRIs per RFC 3987 + // < > " { } | ^ ` \ and control characters + $dangerous = [ + '<' => '%3C', + '>' => '%3E', + '"' => '%22', + '{' => '%7B', + '}' => '%7D', + '|' => '%7C', + '^' => '%5E', + '`' => '%60', + '\\' => '%5C', + ' ' => '%20', + ]; + + $escaped = str_replace( + array_keys($dangerous), + array_values($dangerous), + $uri + ); + + // Escape control characters + $escaped = preg_replace_callback( + '/[\x00-\x1F\x7F]/', + static fn(array $matches): string => sprintf('%%%02X', ord($matches[0])), + $escaped + ) ?? $escaped; + + return $escaped; + } + + /** + * Escape a language tag for Turtle literals. + * + * @param string $tag BCP 47 language tag (e.g., "en", "en-US") + * @return string Validated language tag + * @throws \InvalidArgumentException If tag is invalid + */ + public static function languageTag(string $tag): string + { + // BCP 47 language tag pattern (simplified) + // Full spec: https://tools.ietf.org/html/bcp47 + if (!preg_match('/^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/', $tag)) { + throw new \InvalidArgumentException('Invalid BCP 47 language tag'); + } + + return strtolower($tag); + } + + /** + * Build a complete Turtle literal with optional language tag or datatype. + * + * @param string $value The string value + * @param string|null $language Optional language tag (e.g., "en") + * @param string|null $datatype Optional datatype IRI + * @return string Complete Turtle literal (e.g., "Hello"@en or "42"^^xsd:integer) + */ + public static function literal( + string $value, + ?string $language = null, + ?string $datatype = null + ): string { + $escaped = self::string($value); + $literal = '"' . $escaped . '"'; + + if ($language !== null) { + $literal .= '@' . self::languageTag($language); + } elseif ($datatype !== null) { + // Common XSD prefixes can use shorthand + $xsdPrefix = 'http://www.w3.org/2001/XMLSchema#'; + if (str_starts_with($datatype, $xsdPrefix)) { + $localName = substr($datatype, strlen($xsdPrefix)); + if (preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $localName)) { + $literal .= '^^xsd:' . $localName; + } else { + $literal .= '^^<' . self::iri($datatype) . '>'; + } + } else { + $literal .= '^^<' . self::iri($datatype) . '>'; + } + } + + return $literal; + } + + /** + * Create a safe Turtle triple. + * + * @param string $subject Subject IRI + * @param string $predicate Predicate IRI + * @param string $object Object (string value, will be escaped as literal) + * @param string|null $language Optional language tag + * @return string Complete Turtle triple ending with " ." + */ + public static function triple( + string $subject, + string $predicate, + string $object, + ?string $language = null + ): string { + return sprintf( + '<%s> <%s> %s .', + self::iri($subject), + self::iri($predicate), + self::literal($object, $language) + ); + } + + /** + * Create a safe Turtle triple with IRI object. + * + * @param string $subject Subject IRI + * @param string $predicate Predicate IRI + * @param string $objectIri Object IRI + * @return string Complete Turtle triple ending with " ." + */ + public static function tripleIri( + string $subject, + string $predicate, + string $objectIri + ): string { + return sprintf( + '<%s> <%s> <%s> .', + self::iri($subject), + self::iri($predicate), + self::iri($objectIri) + ); + } +} diff --git a/src/Validator.php b/src/Validator.php index 826ee38..32ec37c 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -1,27 +1,132 @@