Skip to content

feat: support font URLs in GPDFAPI::add_pdf_font() - #1692

Draft
jakejackson1 wants to merge 1 commit into
developmentfrom
feat/add-pdf-font-urls-development
Draft

feat: support font URLs in GPDFAPI::add_pdf_font()#1692
jakejackson1 wants to merge 1 commit into
developmentfrom
feat/add-pdf-font-urls-development

Conversation

@jakejackson1

Copy link
Copy Markdown
Member

Summary

GPDFAPI::add_pdf_font() now accepts an http(s) URL for any of the four font keys (regular, italics, bold, bolditalics) alongside the existing absolute server path. The two forms can be mixed in the one call:

GPDFAPI::add_pdf_font( [
    'font_name' => 'Lato',
    'regular'   => '/full/path/to/Lato-Regular.ttf',
    'bold'      => 'https://example.com/fonts/Lato-Bold.ttf',
] );

URLs are fetched with wp_remote_get(). Failures return WP_Error( 'font_download_error', [ '<font key>' => '<message>' ] ), matching the shape of the existing font_validation_error.

Security notes

Worth a careful look, since this adds an outbound fetch driven by a caller-supplied URL:

  • wp_http_validate_url() runs before the request, not just via reject_unsafe_urls. WP applies that flag after the pre_http_request short-circuit, so a plugin filtering that hook would otherwise bypass the SSRF check entirely. This blocks loopback/private/0.0.0.0 hosts, embedded credentials, and non-standard ports. reject_unsafe_urls is still passed so each redirect hop is revalidated; redirects capped at 3, timeout 30s.
  • Anything with a scheme:// is routed to the downloader and rejected unless it is http/https — it never falls through to the local-path branch. This matters because is_file( 'file:///etc/passwd' ) returns true.
  • stream => true — the body never enters PHP memory.
  • limit_response_size is MAX_FILE_SIZE + 1, not MAX_FILE_SIZE. The transport truncates rather than errors when the cap is hit, so capping at exactly the limit would let a silently-truncated font through as if complete. The extra byte guarantees the post-download size check catches it. Cap is 32 MB, large enough for CJK fonts.
  • Filename is derived from the decoded URL path: basename() first (discards traversal), then sanitize_file_name(), then a hard .ttf requirement.
  • Content is still proven to be a real TTF by the existing TtfFontValidation (mPDF metrics parse) before it is copied into the font directory.
  • Temp files are cleaned up in a finally, alongside the $_FILES restore, so neither leaks on any exit path.

Scope was deliberately kept to GPDFAPI::add_pdf_font(). Moving the fetch into Controller_Custom_Fonts would hand a server-side fetch primitive to the REST endpoint, letting any gravityforms_edit_forms user probe internal addresses through the admin UI — an SSRF surface expansion that isn't needed here (the JS font manager posts multipart uploads and has no URL use case).

Incidental cleanup

The synthetic $_FILES entry drops its file and size keys. The vendored upload library (Upload\File::__construct) reads only tmp_name, name and error, so 'file' => file_get_contents( … ) was loading every font fully into memory for nothing.

Testing

  • tests/phpunit/integration/Helper/Fonts/Test_RemoteFontDownloader.php (new) — every SSRF rejection case, proof that a rejected URL never reaches the HTTP layer, filename derivation (encoded spaces/slashes, traversal, query strings, uppercase extension), the size boundary at exactly max and max+1, and temp-file cleanup on both success and failure.
  • tests/phpunit/integration/Test_Api.php — URL install, mixed URL+path install, download failure wiring, and a 200 response whose body isn't a font (still rejected by the TTF validator).

All mock pre_http_request and use IP literals, so no DNS or network is touched.

Full suite green (1613 tests, single-site and multisite for the affected classes); PHPCS clean.

Follow-up (not in this PR)

Controller_Save_Core_Fonts::download_and_save_font() is a second wp_remote_get( stream ) font fetcher with no size cap, no reject_unsafe_urls and no redirect limit — it relies solely on its GitHub repo allowlist. Worth folding onto RemoteFontDownloader separately.

🤖 Generated with Claude Code

Each of the four font keys now accepts an http(s) URL as well as an
absolute server path, and the two can be mixed within the one call.

Downloads go through the new RemoteFontDownloader, which streams the
response to a temp file with wp_remote_get(). The URL is put through
wp_http_validate_url() *before* the request rather than relying solely on
reject_unsafe_urls: WP applies that flag after the pre_http_request
short-circuit, so a plugin filtering that hook would otherwise bypass the
SSRF check entirely. reject_unsafe_urls is still passed so each redirect
hop is revalidated.

Anything with a scheme is routed to the downloader (and rejected unless
it is http/https) instead of falling through to the local-path branch,
because is_file('file:///etc/passwd') returns true.

limit_response_size is set to MAX_FILE_SIZE + 1 rather than MAX_FILE_SIZE
so a truncated oversized response always trips the post-download size
check; capping at exactly the limit would let a silently-truncated font
through as if it were complete. Content is still proven to be a real TTF
by the existing TtfFontValidation before it reaches the font directory.

The synthetic $_FILES entry drops the 'file' and 'size' keys - the
vendored upload library reads only tmp_name/name/error, so 'file' was
loading every font fully into memory for nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage report for commit: 43efeee
File: ./tmp/jest-coverage/clover.xml

Cover ┌─────────────────────────┐ Freq.
   0% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  10% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  20% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  30% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  40% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  50% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  60% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  70% │ █░░░░░░░░░░░░░░░░░░░░░░ │  1.6%
  80% │ ███░░░░░░░░░░░░░░░░░░░░ │  8.1%
  90% │ ███████░░░░░░░░░░░░░░░░ │ 21.0%
 100% │ ███████████████████████ │ 69.4%
      └─────────────────────────┘
 *Legend:* █ = Current Distribution 
Summary - Lines: 92.99% | Methods: 88.31% | Branches: 81.37%
FilesLinesMethodsBranches
src/assets/js/react/actions
   coreFonts.js100.00%100.00%100.00%
   fontManager.js100.00%100.00%100.00%
   templates.js100.00%100.00%100.00%
src/assets/js/react/components/Alert
   Alert.js100.00%100.00%100.00%
src/assets/js/react/components/CoreFonts
   CoreFontContainer.js100.00%100.00%91.43%
   CoreFontCounter.js100.00%100.00%100.00%
   CoreFontListResults.js100.00%100.00%85.71%
   CoreFontListSpacer.js100.00%100.00%100.00%
src/assets/js/react/components
   CustomHashRouter.js100.00%100.00%100.00%
   Empty.js100.00%100.00%100.00%
   ShowMessage.js79.31%80.00%64.29%
   Spinner.js100.00%100.00%100.00%
src/assets/js/react/components/FontManager
   AddFont.js100.00%100.00%100.00%
   AddUpdateFontFooter.js85.37%50.00%88.89%
   AdvancedButton.js100.00%100.00%100.00%
   FontList.js100.00%50.00%65.22%
   FontListAlertMessage.js100.00%100.00%100.00%
   FontListHeader.js100.00%100.00%100.00%
   FontListIcon.js100.00%100.00%100.00%
   FontListItems.js85.39%64.00%68.66%
   FontListSkeleton.js100.00%100.00%100.00%
   FontManager.js77.78%57.14%50.00%
   FontManagerBody.js94.20%96.43%90.29%
   FontManagerHeader.js100.00%100.00%100.00%
   FontVariant.js90.00%60.00%70.00%
   FontVariantLabel.js100.00%100.00%100.00%
   InitialAddUpdateState.js100.00%100.00%100.00%
   SearchBox.js90.00%66.67%69.23%
   TemplateTooltip.js100.00%75.00%100.00%
   UpdateFont.js75.00%50.00%75.00%
src/assets/js/react/components/Modal
   CloseDialog.js93.33%66.67%70.59%
src/assets/js/react/components/Template
   TemplateActivateButton.js100.00%100.00%100.00%
   TemplateButton.js85.71%66.67%100.00%
   TemplateContainer.js71.43%66.67%25.00%
   TemplateDeleteButton.js100.00%100.00%70.00%
   TemplateFooterActions.js100.00%100.00%100.00%
   TemplateHeaderNavigation.js82.35%85.71%70.00%
   TemplateHeaderTitle.js100.00%100.00%100.00%
   TemplateList.js100.00%100.00%60.00%
   TemplateListItem.js100.00%100.00%92.86%
   TemplateListItemComponents.js100.00%100.00%100.00%
   TemplateScreenshot.js100.00%100.00%100.00%
   TemplateScreenshots.js100.00%100.00%50.00%
   TemplateSearch.js93.75%88.89%50.00%
   TemplateSingle.js100.00%100.00%100.00%
   TemplateSingleComponents.js100.00%100.00%75.00%
   TemplateUploader.js98.04%100.00%86.67%
src/assets/js/react/reducers
   coreFontReducer.js95.65%100.00%88.00%
   fontManagerReducer.js87.21%75.00%75.00%
   index.js100.00%100.00%100.00%
   templateReducer.js100.00%100.00%100.00%
src/assets/js/react/sagas
   coreFonts.js91.67%100.00%75.00%
   fontManager.js86.96%90.00%83.33%
   index.js100.00%100.00%100.00%
   templates.js83.33%100.00%100.00%
src/assets/js/react/selectors
   getTemplates.js91.11%100.00%83.33%
src/assets/js/react/utilities/FontManager
   adjustFontListHeight.js100.00%100.00%100.00%
   associatedFontManagerSelectBox.js94.44%100.00%66.67%
   fontManagerReducer.js100.00%100.00%100.00%
   getTabLocation.js100.00%100.00%100.00%
   toggleUpdateFont.js100.00%100.00%100.00%
src/assets/js/react/utilities
   withRouterHooks.js100.00%100.00%100.00%

🤖 Jest coverage report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant