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
49 changes: 40 additions & 9 deletions includes/class-content-proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ private function serve_css_with_rewritten_urls( $full_path, $hash, $mime_type, $
return;
}

$base_url = rest_url( 'exelearning/v1/content/' . $hash . '/' );
$base_url = self::get_uploads_url( $hash );

// Get the directory of the current CSS file for resolving relative paths.
$current_dir = '';
Expand Down Expand Up @@ -301,7 +301,8 @@ function ( $matches ) use ( $base_url, $current_dir ) {
* @return string Modified HTML with absolute URLs.
*/
private function rewrite_relative_urls( $html, $hash, $file_path = '' ) {
$base_url = rest_url( 'exelearning/v1/content/' . $hash . '/' );
$uploads_url = self::get_uploads_url( $hash );
$proxy_url = self::get_proxy_url( $hash, '' );

// Get the directory of the current file for resolving relative paths.
$current_dir = '';
Expand All @@ -325,7 +326,7 @@ private function rewrite_relative_urls( $html, $hash, $file_path = '' ) {
foreach ( $patterns as $pattern ) {
$html = preg_replace_callback(
$pattern,
function ( $matches ) use ( $base_url, $current_dir ) {
function ( $matches ) use ( $uploads_url, $proxy_url, $current_dir ) {
$prefix = $matches[1];
$attr = $matches[2];
$url = $matches[3];
Expand All @@ -339,33 +340,47 @@ function ( $matches ) use ( $base_url, $current_dir ) {
// Resolve the relative URL based on current directory.
$resolved_path = $this->resolve_relative_path( $current_dir, $url );

// Build absolute URL.
$absolute_url = $base_url . $resolved_path;
// HTML files go through the proxy (for CSP headers);
// all other assets are served directly from uploads.
$base_url = self::is_html_path( $resolved_path ) ? $proxy_url : $uploads_url;

return $prefix . $attr . esc_url( $absolute_url ) . $end_quote;
return $prefix . $attr . esc_url( $base_url . $resolved_path ) . $end_quote;
},
$html
);
}

// Also handle url() in inline styles.
// Also handle url() in inline styles (never HTML, always assets).
$html = preg_replace_callback(
'/url\s*\(\s*["\']?(?!https?:\/\/|data:|\/\/|#)([^"\')\s]+)["\']?\s*\)/i',
function ( $matches ) use ( $base_url, $current_dir ) {
function ( $matches ) use ( $uploads_url, $current_dir ) {
$url = $matches[1];
if ( empty( $url ) || '/' === $url[0] ) {
return $matches[0];
}
// Resolve the relative URL based on current directory.
$resolved_path = $this->resolve_relative_path( $current_dir, $url );
return 'url("' . esc_url( $base_url . $resolved_path ) . '")';
return 'url("' . esc_url( $uploads_url . $resolved_path ) . '")';
},
$html
);

return $html;
}

/**
* Check if a file path points to an HTML file.
*
* @param string $path File path to check.
* @return bool True if the path ends with .html or .htm.
*/
private static function is_html_path( $path ) {
// Strip query string and fragment before checking extension.
$clean_path = strtok( $path, '?#' );
$extension = strtolower( pathinfo( $clean_path, PATHINFO_EXTENSION ) );
return 'html' === $extension || 'htm' === $extension;
}

/**
* Resolve a relative path against a base directory.
*
Expand Down Expand Up @@ -501,4 +516,20 @@ public static function get_proxy_url( $hash, $file = 'index.html' ) {
}
return rest_url( 'exelearning/v1/content/' . $hash . '/' . $file );
}

/**
* Generate a direct uploads URL for the given hash and file.
*
* Sub-assets (CSS, JS, images, fonts) are served directly from the uploads
* directory to avoid 404s on hosted environments where the web server
* intercepts requests with static file extensions.
*
* @param string $hash Extraction hash.
* @param string $file File path (default: empty).
* @return string Uploads URL.
*/
public static function get_uploads_url( $hash, $file = '' ) {
$upload_dir = wp_upload_dir();
return trailingslashit( $upload_dir['baseurl'] ) . 'exelearning/' . $hash . '/' . $file;
}
}
52 changes: 31 additions & 21 deletions includes/class-elp-upload-handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,41 +189,51 @@ public function exelearning_delete_extracted_folder( $post_id ) {
}

/**
* Creates a security .htaccess file to block direct access to extracted content.
* Creates a security .htaccess file to control direct access to extracted content.
*
* All content must be served through the secure proxy controller.
* HTML files are blocked (must be served through the secure proxy for CSP headers).
* Static assets (CSS, JS, images, fonts, media) are allowed for direct serving,
* which avoids 403/404 errors on hosted environments where the web server
* intercepts requests with static file extensions.
*/
private function create_security_htaccess() {
$upload_dir = wp_upload_dir();
$htaccess_path = trailingslashit( $upload_dir['basedir'] ) . 'exelearning/.htaccess';

// Only create if it doesn't exist.
if ( file_exists( $htaccess_path ) ) {
return;
}

$htaccess_content = <<<'HTACCESS'
# Security: Block direct access to eXeLearning extracted content
# All content must be served through the secure proxy controller
# Security: Control direct access to eXeLearning extracted content
# HTML files must be served through the secure proxy controller (for CSP headers)
# Static assets (CSS, JS, images, fonts, media) are allowed for direct serving

# Deny all direct access
<IfModule mod_authz_core.c>
# Apache 2.4+
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
# Apache 2.2
Order deny,allow
Deny from all
</IfModule>

# Alternative: return 403 for all requests
<IfModule mod_rewrite.c>
RewriteEngine On

# Allow static assets to be served directly
RewriteCond %{REQUEST_URI} \.(css|js|json|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|eot|otf|mp[34]|webm|og[gv]|wav|pdf|zip|txt|xml)$ [NC]
RewriteRule ^ - [L]

# Block direct access to everything else (HTML files, etc.)
RewriteRule ^ - [F,L]
</IfModule>

<IfModule !mod_rewrite.c>
# Fallback without mod_rewrite: deny all (proxy will still work)
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</IfModule>
HTACCESS;

// Only write if the file doesn't exist or its content has changed.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( file_exists( $htaccess_path ) && file_get_contents( $htaccess_path ) === $htaccess_content ) {
return;
}

// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $htaccess_path, $htaccess_content );
}
Expand Down
39 changes: 35 additions & 4 deletions tests/unit/ContentProxyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ public function test_rewrite_relative_urls_basic() {
$hash = str_repeat( 'a', 40 );
$result = $method->invoke( $this->proxy, $html, $hash, '' );

$this->assertStringContainsString( 'exelearning/v1/content/', $result );
$this->assertStringContainsString( 'uploads/exelearning/', $result );
$this->assertStringContainsString( 'images/logo.png', $result );
}

Expand Down Expand Up @@ -707,7 +707,7 @@ public function test_rewrite_relative_urls_handles_href() {
$hash = str_repeat( 'a', 40 );
$result = $method->invoke( $this->proxy, $html, $hash, '' );

$this->assertStringContainsString( 'exelearning/v1/content/', $result );
$this->assertStringContainsString( 'uploads/exelearning/', $result );
$this->assertStringContainsString( 'css/style.css', $result );
}

Expand All @@ -722,7 +722,7 @@ public function test_rewrite_relative_urls_handles_poster() {
$hash = str_repeat( 'a', 40 );
$result = $method->invoke( $this->proxy, $html, $hash, '' );

$this->assertStringContainsString( 'exelearning/v1/content/', $result );
$this->assertStringContainsString( 'uploads/exelearning/', $result );
$this->assertStringContainsString( 'thumbnails/video.jpg', $result );
}

Expand All @@ -737,7 +737,7 @@ public function test_rewrite_relative_urls_handles_inline_style() {
$hash = str_repeat( 'a', 40 );
$result = $method->invoke( $this->proxy, $html, $hash, '' );

$this->assertStringContainsString( 'exelearning/v1/content/', $result );
$this->assertStringContainsString( 'uploads/exelearning/', $result );
$this->assertStringContainsString( 'images/bg.png', $result );
}

Expand Down Expand Up @@ -854,6 +854,37 @@ public function test_rewrite_relative_urls_absolute_path() {
$this->assertEquals( $html, $result );
}

/**
* Test rewrite_relative_urls routes HTML links through proxy.
*/
public function test_rewrite_relative_urls_html_links_use_proxy() {
$method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'rewrite_relative_urls' );
$method->setAccessible( true );

$html = '<a href="html/page2.html">Next</a>';
$hash = str_repeat( 'a', 40 );
$result = $method->invoke( $this->proxy, $html, $hash, '' );

$this->assertStringContainsString( 'exelearning/v1/content/', $result );
$this->assertStringContainsString( 'html/page2.html', $result );
$this->assertStringNotContainsString( 'uploads/exelearning/', $result );
}

/**
* Test rewrite_relative_urls routes HTM links through proxy.
*/
public function test_rewrite_relative_urls_htm_links_use_proxy() {
$method = new ReflectionMethod( ExeLearning_Content_Proxy::class, 'rewrite_relative_urls' );
$method->setAccessible( true );

$html = '<a href="page.htm">Page</a>';
$hash = str_repeat( 'a', 40 );
$result = $method->invoke( $this->proxy, $html, $hash, '' );

$this->assertStringContainsString( 'exelearning/v1/content/', $result );
$this->assertStringNotContainsString( 'uploads/exelearning/', $result );
}

/**
* Test validate_hash with valid hash.
*/
Expand Down