diff --git a/api.php b/api.php index 6d0fb2806..4fe71729d 100644 --- a/api.php +++ b/api.php @@ -647,18 +647,21 @@ public static function get_pdf_fonts() { * 'font_name' => 'Lato', * 'regular' => '/full/path/to/font/Lato-Regular.ttf', * 'italics' => '/full/path/to/font/Lato-Italic.ttf', - * 'bold' => '/full/path/to/font/Lato-Bold.ttf', - * 'bolditalics' => '/full/path/to/font/Lato-BoldItalic.ttf', + * 'bold' => 'https://example.com/fonts/Lato-Bold.ttf', + * 'bolditalics' => 'https://example.com/fonts/Lato-BoldItalic.ttf', * ) * * Only the 'font_name' and 'regular' keys are required. - * All fonts should be referenced with the full server path. + * Fonts should be referenced with the full server path, or a http(s) URL that is downloaded + * with wp_remote_get(). URLs must point to a .ttf file, resolve to a public address, and the + * response cannot exceed 32MB. * Currently, only .ttf fonts are supported. * The font name can only contain alphanumeric characters, or a space * * @return bool|WP_Error * * @since 4.1 + * @since 6.17 Added support for http(s) URLs */ public static function add_pdf_font( $font ) { @@ -683,30 +686,43 @@ public static function add_pdf_font( $font ) { $request = new WP_REST_Request(); $request->set_param( 'label', $font['font_name'] ?? '' ); - foreach ( $controller->get_font_keys() as $id ) { - if ( isset( $font[ $id ] ) && is_file( $font[ $id ] ) ) { - /* phpcs:disable WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents */ - $_FILES[ $id ] = [ - 'file' => file_get_contents( $font[ $id ] ), - 'name' => basename( $font[ $id ] ), - 'size' => filesize( $font[ $id ] ), - 'tmp_name' => $font[ $id ], - 'error' => UPLOAD_ERR_OK, - ]; + $downloader = new \GFPDF\Helper\Fonts\RemoteFontDownloader(); + + $id = ''; + $path = ''; + + try { + foreach ( $controller->get_font_keys() as $id ) { + $path = $font[ $id ] ?? ''; + if ( ! is_string( $path ) || $path === '' ) { + continue; + } + + /* Abort on the first bad URL rather than downloading fonts this call is going to discard */ + if ( \GFPDF\Helper\Fonts\RemoteFontDownloader::is_url( $path ) ) { + $_FILES[ $id ] = $downloader->download( $path ); + } elseif ( is_file( $path ) ) { + $_FILES[ $id ] = [ + 'name' => basename( $path ), + 'tmp_name' => $path, + 'error' => UPLOAD_ERR_OK, + ]; + } } - } - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- $_FILES populated above from $font arg, not a form post. - $request->set_file_params( $_FILES ); - $response = $controller->add_item( $request ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- $_FILES populated above from $font arg, not a form post. + $request->set_file_params( $_FILES ); + $response = $controller->add_item( $request ); - $_FILES = $files_backup; + return is_wp_error( $response ) ? $response : true; + } catch ( \GFPDF\Exceptions\GravityPdfFontDownloadException $e ) { + self::get_log_class()->error( $e->getMessage(), [ 'url' => $path ] ); - if ( is_wp_error( $response ) ) { - return $response; + return new WP_Error( 'font_download_error', [ $id => $e->getMessage() ], [ 'status' => 400 ] ); + } finally { + $_FILES = $files_backup; + $downloader->cleanup(); } - - return true; } /** diff --git a/src/Exceptions/GravityPdfFontDownloadException.php b/src/Exceptions/GravityPdfFontDownloadException.php new file mode 100644 index 000000000..abd2d6706 --- /dev/null +++ b/src/Exceptions/GravityPdfFontDownloadException.php @@ -0,0 +1,24 @@ +get_filename_from_url( $url ); + + if ( ! function_exists( 'wp_tempnam' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $tmp_name = wp_tempnam( $filename ); + $this->temp_files[] = $tmp_name; + + $response = wp_remote_get( + $url, + [ + 'timeout' => 30, + /* Each redirect target is revalidated against reject_unsafe_urls by WP_Http */ + 'redirection' => 3, + 'reject_unsafe_urls' => true, + 'limit_response_size' => static::MAX_FILE_SIZE + 1, + 'stream' => true, + 'filename' => $tmp_name, + ] + ); + + if ( is_wp_error( $response ) ) { + /* translators: %s: the HTTP error message */ + throw new GravityPdfFontDownloadException( sprintf( esc_html__( 'Could not download the font: %s', 'gravity-pdf' ), esc_html( $response->get_error_message() ) ) ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + if ( $status !== 200 ) { + /* translators: %d: the HTTP status code returned by the remote server */ + throw new GravityPdfFontDownloadException( sprintf( esc_html__( 'Could not download the font. The server responded with a %d status code.', 'gravity-pdf' ), esc_html( (string) $status ) ) ); + } + + clearstatcache( true, $tmp_name ); + $size = (int) @filesize( $tmp_name ); /* phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged */ + + if ( $size === 0 ) { + throw new GravityPdfFontDownloadException( esc_html__( 'The downloaded font file is empty.', 'gravity-pdf' ) ); + } + + /* The response is capped at MAX_FILE_SIZE + 1 bytes, so anything larger is either oversized or truncated */ + if ( $size > static::MAX_FILE_SIZE ) { + throw new GravityPdfFontDownloadException( + sprintf( + /* translators: %s: the maximum font file size e.g. 32 MB */ + esc_html__( 'The font file exceeds the maximum size of %s.', 'gravity-pdf' ), + esc_html( (string) size_format( static::MAX_FILE_SIZE ) ) + ) + ); + } + + return [ + 'name' => $filename, + 'tmp_name' => $tmp_name, + 'error' => UPLOAD_ERR_OK, + ]; + } + + /** + * Delete any temporary files created by this instance + * + * @since 6.17 + */ + public function cleanup(): void { + foreach ( $this->temp_files as $file ) { + if ( is_file( $file ) ) { + wp_delete_file( $file ); + } + } + + $this->temp_files = []; + } + + /** + * Derive a safe .ttf filename from the URL path + * + * @throws GravityPdfFontDownloadException + * + * @since 6.17 + */ + protected function get_filename_from_url( string $url ): string { + $path = rawurldecode( (string) wp_parse_url( $url, PHP_URL_PATH ) ); + + /* basename() before sanitizing so any traversal segments in the decoded path are discarded */ + $filename = sanitize_file_name( basename( $path ) ); + + if ( strtolower( (string) pathinfo( $filename, PATHINFO_EXTENSION ) ) !== 'ttf' ) { + throw new GravityPdfFontDownloadException( esc_html__( 'The font URL must point to a .ttf file.', 'gravity-pdf' ) ); + } + + return $filename; + } +} diff --git a/tests/phpunit/integration/Helper/Fonts/Test_RemoteFontDownloader.php b/tests/phpunit/integration/Helper/Fonts/Test_RemoteFontDownloader.php new file mode 100644 index 000000000..c855cff64 --- /dev/null +++ b/tests/phpunit/integration/Helper/Fonts/Test_RemoteFontDownloader.php @@ -0,0 +1,324 @@ +downloader = new RemoteFontDownloader(); + $this->request_args = []; + } + + public function tear_down(): void { + $this->downloader->cleanup(); + + parent::tear_down(); + } + + /** + * Short-circuit wp_remote_get() and write $body to the stream target, mimicking a real download + * + * @param callable|string $body String written to the stream target, or a callable given the target path + */ + private function mock_http( $body = '', int $status = 200, ?\WP_Error $error = null ): void { + add_filter( + 'pre_http_request', + function ( $preempt, $args ) use ( $body, $status, $error ) { + $this->request_args = $args; + + if ( $error !== null ) { + return $error; + } + + if ( ! empty( $args['filename'] ) ) { + if ( is_callable( $body ) ) { + $body( $args['filename'] ); + } else { + file_put_contents( $args['filename'], $body ); + } + } + + return [ + 'headers' => [], + 'body' => '', + 'response' => [ + 'code' => $status, + 'message' => get_status_header_desc( $status ), + ], + 'cookies' => [], + 'filename' => $args['filename'] ?? null, + ]; + }, + 10, + 2 + ); + } + + private function chewy(): string { + return (string) file_get_contents( PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/Chewy.ttf' ); + } + + /** + * @dataProvider provider_is_url + */ + public function test_is_url( bool $expected, string $value ): void { + $this->assertSame( $expected, RemoteFontDownloader::is_url( $value ) ); + } + + public function provider_is_url(): array { + return [ + 'http' => [ true, 'http://example.com/font.ttf' ], + 'https' => [ true, 'https://example.com/font.ttf' ], + 'uppercase' => [ true, 'HTTPS://example.com/font.ttf' ], + 'ftp' => [ true, 'ftp://example.com/font.ttf' ], + 'file' => [ true, 'file:///etc/passwd' ], + 'php stream' => [ true, 'php://filter/resource=/etc/passwd' ], + 'absolute path' => [ false, '/var/www/fonts/font.ttf' ], + 'windows path' => [ false, 'C:\\fonts\\font.ttf' ], + 'relative path' => [ false, 'fonts/font.ttf' ], + 'protocol-less' => [ false, '//example.com/font.ttf' ], + 'empty' => [ false, '' ], + 'name only' => [ false, 'font.ttf' ], + ]; + } + + /** + * @dataProvider provider_rejected_urls + */ + public function test_download_rejects_url( string $url, string $expected_message ): void { + $this->mock_http(); + + $this->expectException( GravityPdfFontDownloadException::class ); + $this->expectExceptionMessage( $expected_message ); + + $this->downloader->download( $url ); + } + + public function provider_rejected_urls(): array { + return [ + 'ftp scheme' => [ 'ftp://93.184.216.34/font.ttf', 'Only http:// and https:// font URLs are supported.' ], + 'file scheme' => [ 'file:///etc/passwd', 'Only http:// and https:// font URLs are supported.' ], + 'php stream' => [ 'php://filter/resource=/etc/passwd', 'Only http:// and https:// font URLs are supported.' ], + 'no scheme' => [ '/var/www/fonts/font.ttf', 'Only http:// and https:// font URLs are supported.' ], + 'loopback' => [ 'http://127.0.0.1/font.ttf', 'resolves to a restricted address' ], + 'private class a' => [ 'http://10.0.0.5/font.ttf', 'resolves to a restricted address' ], + 'private class b' => [ 'http://172.16.4.1/font.ttf', 'resolves to a restricted address' ], + 'private class c' => [ 'http://192.168.1.1/font.ttf', 'resolves to a restricted address' ], + 'link local' => [ 'http://0.0.0.0/font.ttf', 'resolves to a restricted address' ], + 'blocked port' => [ 'http://93.184.216.34:22/font.ttf', 'resolves to a restricted address' ], + 'credentials' => [ 'http://user:pass@93.184.216.34/font.ttf', 'resolves to a restricted address' ], + 'not a ttf' => [ 'http://93.184.216.34/font.otf', 'The font URL must point to a .ttf file.' ], + 'no extension' => [ 'http://93.184.216.34/font', 'The font URL must point to a .ttf file.' ], + 'no path' => [ 'http://93.184.216.34', 'The font URL must point to a .ttf file.' ], + ]; + } + + /** + * A rejected URL must never reach the HTTP layer + */ + public function test_download_does_not_request_rejected_url(): void { + $this->mock_http(); + + try { + $this->downloader->download( 'http://127.0.0.1/font.ttf' ); + } catch ( GravityPdfFontDownloadException $e ) { + $this->assertSame( [], $this->request_args ); + + return; + } + + $this->fail( 'Expected a GravityPdfFontDownloadException.' ); + } + + public function test_download_returns_file_details(): void { + $font = $this->chewy(); + $this->mock_http( $font ); + + $file = $this->downloader->download( self::REMOTE_URL ); + + $this->assertSame( 'Chewy.ttf', $file['name'] ); + $this->assertSame( UPLOAD_ERR_OK, $file['error'] ); + $this->assertFileExists( $file['tmp_name'] ); + $this->assertSame( $font, file_get_contents( $file['tmp_name'] ) ); + } + + public function test_download_uses_hardened_request_args(): void { + $this->mock_http( $this->chewy() ); + $this->downloader->download( self::REMOTE_URL ); + + $this->assertTrue( $this->request_args['reject_unsafe_urls'] ); + $this->assertTrue( $this->request_args['stream'] ); + $this->assertSame( RemoteFontDownloader::MAX_FILE_SIZE + 1, $this->request_args['limit_response_size'] ); + $this->assertSame( 3, $this->request_args['redirection'] ); + $this->assertSame( 30, $this->request_args['timeout'] ); + $this->assertNotEmpty( $this->request_args['filename'] ); + } + + /** + * The font is streamed to a temp file, not into the custom font directory + */ + public function test_download_streams_to_temp_directory(): void { + $this->mock_http( $this->chewy() ); + + $file = $this->downloader->download( self::REMOTE_URL ); + + $this->assertStringStartsWith( get_temp_dir(), $file['tmp_name'] ); + $this->assertFileDoesNotExist( $this->gfpdf()->data->template_font_location . 'Chewy.ttf' ); + } + + /** + * @dataProvider provider_filenames + */ + public function test_download_derives_filename_from_url( string $expected, string $url ): void { + $this->mock_http( $this->chewy() ); + + $file = $this->downloader->download( $url ); + + $this->assertSame( $expected, $file['name'] ); + } + + public function provider_filenames(): array { + return [ + 'plain' => [ 'Chewy.ttf', 'http://93.184.216.34/Chewy.ttf' ], + 'nested path' => [ 'Chewy.ttf', 'http://93.184.216.34/a/b/c/Chewy.ttf' ], + 'query string' => [ 'Chewy.ttf', 'http://93.184.216.34/Chewy.ttf?v=2&x=1' ], + 'fragment' => [ 'Chewy.ttf', 'http://93.184.216.34/Chewy.ttf#frag' ], + 'encoded space' => [ 'My-Font.ttf', 'http://93.184.216.34/My%20Font.ttf' ], + 'encoded slash' => [ 'Chewy.ttf', 'http://93.184.216.34/fonts%2FChewy.ttf' ], + 'traversal' => [ 'Chewy.ttf', 'http://93.184.216.34/a/../../Chewy.ttf' ], + 'uppercase ext' => [ 'Chewy.TTF', 'http://93.184.216.34/Chewy.TTF' ], + ]; + } + + public function test_download_throws_on_http_error(): void { + $this->mock_http( '', 200, new \WP_Error( 'http_request_failed', 'Connection timed out' ) ); + + $this->expectException( GravityPdfFontDownloadException::class ); + $this->expectExceptionMessage( 'Connection timed out' ); + + $this->downloader->download( self::REMOTE_URL ); + } + + /** + * @dataProvider provider_error_status_codes + */ + public function test_download_throws_on_non_200_response( int $status ): void { + $this->mock_http( $this->chewy(), $status ); + + $this->expectException( GravityPdfFontDownloadException::class ); + $this->expectExceptionMessage( sprintf( 'The server responded with a %d status code.', $status ) ); + + $this->downloader->download( self::REMOTE_URL ); + } + + public function provider_error_status_codes(): array { + return [ [ 301 ], [ 401 ], [ 403 ], [ 404 ], [ 500 ] ]; + } + + public function test_download_throws_on_empty_response(): void { + $this->mock_http( '' ); + + $this->expectException( GravityPdfFontDownloadException::class ); + $this->expectExceptionMessage( 'The downloaded font file is empty.' ); + + $this->downloader->download( self::REMOTE_URL ); + } + + /** + * Write a sparse file of $bytes — the size the transport truncates an oversized response to + */ + private function mock_http_body_of_size( int $bytes ): void { + $this->mock_http( + function ( $path ) use ( $bytes ) { + $fh = fopen( $path, 'w' ); + fseek( $fh, $bytes - 1 ); + fwrite( $fh, 'a' ); + fclose( $fh ); + } + ); + } + + public function test_download_throws_when_response_exceeds_max_size(): void { + $this->mock_http_body_of_size( RemoteFontDownloader::MAX_FILE_SIZE + 1 ); + + $this->expectException( GravityPdfFontDownloadException::class ); + $this->expectExceptionMessage( 'exceeds the maximum size' ); + + $this->downloader->download( self::REMOTE_URL ); + } + + public function test_download_accepts_response_at_max_size(): void { + $this->mock_http_body_of_size( RemoteFontDownloader::MAX_FILE_SIZE ); + + $file = $this->downloader->download( self::REMOTE_URL ); + + $this->assertSame( RemoteFontDownloader::MAX_FILE_SIZE, filesize( $file['tmp_name'] ) ); + } + + public function test_cleanup_deletes_temp_files(): void { + $this->mock_http( $this->chewy() ); + + $first = $this->downloader->download( self::REMOTE_URL ); + $second = $this->downloader->download( self::REMOTE_URL ); + + $this->assertNotSame( $first['tmp_name'], $second['tmp_name'] ); + + $this->downloader->cleanup(); + + $this->assertFileDoesNotExist( $first['tmp_name'] ); + $this->assertFileDoesNotExist( $second['tmp_name'] ); + } + + /** + * A failed download must not leave the placeholder temp file behind + */ + public function test_cleanup_deletes_temp_file_after_failed_download(): void { + $this->mock_http( '', 404 ); + + try { + $this->downloader->download( self::REMOTE_URL ); + $this->fail( 'Expected a GravityPdfFontDownloadException.' ); + } catch ( GravityPdfFontDownloadException $e ) { + $temp_files = glob( get_temp_dir() . 'Chewy-*.tmp' ); + + $this->downloader->cleanup(); + + $this->assertNotEmpty( $temp_files ); + foreach ( $temp_files as $file ) { + $this->assertFileDoesNotExist( $file ); + } + } + } + + public function test_cleanup_is_idempotent(): void { + $this->mock_http( $this->chewy() ); + $file = $this->downloader->download( self::REMOTE_URL ); + + $this->downloader->cleanup(); + $this->downloader->cleanup(); + + $this->assertFileDoesNotExist( $file['tmp_name'] ); + } +} diff --git a/tests/phpunit/integration/Test_Api.php b/tests/phpunit/integration/Test_Api.php index 80a374566..96d626af3 100644 --- a/tests/phpunit/integration/Test_Api.php +++ b/tests/phpunit/integration/Test_Api.php @@ -292,6 +292,142 @@ public function test_add_pdf_font_duplicate() { GPDFAPI::delete_pdf_font( 'test' ); } + /** + * A public IP literal. Using an IP (instead of a hostname) keeps wp_http_validate_url() from + * making a DNS lookup, so these tests never touch the network. + */ + private const FONT_URL = 'http://93.184.216.34/fonts/Chewy.ttf'; + + /** + * Short-circuit wp_remote_get() and write the fixture font to the stream target + */ + private function mock_font_download( string $body, int $status = 200 ): void { + add_filter( + 'pre_http_request', + function ( $preempt, $args ) use ( $body, $status ) { + if ( ! empty( $args['filename'] ) ) { + file_put_contents( $args['filename'], $body ); + } + + return [ + 'headers' => [], + 'body' => '', + 'response' => [ + 'code' => $status, + 'message' => get_status_header_desc( $status ), + ], + 'cookies' => [], + 'filename' => $args['filename'] ?? null, + ]; + }, + 10, + 2 + ); + } + + /** + * @since 6.17 + */ + public function test_add_pdf_font_from_url() { + $this->mock_font_download( file_get_contents( PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/Chewy.ttf' ) ); + + $results = GPDFAPI::add_pdf_font( + [ + 'font_name' => 'Test', + 'regular' => self::FONT_URL, + ] + ); + + $this->assertTrue( $results ); + $this->assertFileExists( PDF_FONT_LOCATION . 'Chewy.ttf' ); + + /* The temporary download is removed once the font is installed */ + $this->assertSame( [], glob( get_temp_dir() . 'Chewy-*.tmp' ) ); + + GPDFAPI::delete_pdf_font( 'test' ); + } + + /** + * URLs and absolute paths can be mixed within the one font + * + * @since 6.17 + */ + public function test_add_pdf_font_from_url_and_path() { + $this->mock_font_download( file_get_contents( PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/DejaVuSans-Bold.ttf' ) ); + + $results = GPDFAPI::add_pdf_font( + [ + 'font_name' => 'Test', + 'regular' => PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/Chewy.ttf', + 'bold' => 'http://93.184.216.34/fonts/DejaVuSans-Bold.ttf', + ] + ); + + $this->assertTrue( $results ); + + $font = GPDFAPI::get_mvc_class( 'Model_Custom_Fonts' )->get_font_by_id( 'test' ); + $this->assertSame( PDF_FONT_LOCATION . 'Chewy.ttf', $font['regular'] ); + $this->assertFileExists( $font['bold'] ); + + GPDFAPI::delete_pdf_font( 'test' ); + } + + /** + * A download failure surfaces as a font_download_error keyed by the font slot that failed + * + * The rejection rules themselves are covered by Test_RemoteFontDownloader; this only pins the wiring. + * + * @dataProvider provider_invalid_font_urls + * + * @since 6.17 + */ + public function test_add_pdf_font_url_errors( string $url, int $status ) { + $this->mock_font_download( file_get_contents( PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/Chewy.ttf' ), $status ); + + $results = GPDFAPI::add_pdf_font( + [ + 'font_name' => 'Test', + 'regular' => $url, + ] + ); + + $this->assertInstanceOf( \WP_Error::class, $results ); + $this->assertSame( 'font_download_error', $results->get_error_code() ); + $this->assertArrayHasKey( 'regular', $results->get_error_message() ); + + /* Nothing is installed, and no temporary file is left behind */ + $this->assertArrayNotHasKey( 'User-Defined Fonts', GPDFAPI::get_pdf_fonts() ); + $this->assertSame( [], glob( get_temp_dir() . 'Chewy-*.tmp' ) ); + } + + public function provider_invalid_font_urls(): array { + return [ + 'rejected before the request' => [ 'http://127.0.0.1/fonts/Chewy.ttf', 200 ], + 'rejected after the request' => [ self::FONT_URL, 404 ], + ]; + } + + /** + * A URL that downloads successfully but isn't a font is still rejected by the TTF validator + * + * @since 6.17 + */ + public function test_add_pdf_font_url_rejects_non_font_content() { + $this->mock_font_download( 'not a font' ); + + $results = GPDFAPI::add_pdf_font( + [ + 'font_name' => 'Test', + 'regular' => self::FONT_URL, + ] + ); + + $this->assertInstanceOf( \WP_Error::class, $results ); + $this->assertSame( 'font_validation_error', $results->get_error_code() ); + $this->assertFileDoesNotExist( PDF_FONT_LOCATION . 'Chewy.ttf' ); + $this->assertSame( [], glob( get_temp_dir() . 'Chewy-*.tmp' ) ); + } + /** * Test we can correctly delete the font *