| 1 |
<?php |
| 2 |
if ( ! defined( 'ABSPATH' ) ) { |
| 3 |
exit; |
| 4 |
} |
| 5 |
|
| 6 |
// Make sure we instantiate the library so any library specific filters/setup get run |
| 7 |
$library = $this->get_post_meta( $post->ID, 'library' ); |
| 8 |
$this->library( $library ); |
| 9 |
|
| 10 |
// Resolve all CSP allowlists and font descriptors upfront so we can emit one header before any output begins |
| 11 |
$style_urls = apply_filters( 'm_chart_iframe_styles', [], $post->ID ); |
| 12 |
$inline_styles = apply_filters( 'm_chart_iframe_inline_styles', [], $post->ID ); |
| 13 |
$font_descriptors = apply_filters( 'm_chart_iframe_fonts', [], $post->ID ); |
| 14 |
|
| 15 |
// Default allowed style-src hosts cover the well-known font-service stylesheet origins |
| 16 |
// Site owners using Fontspring, FontAwesome CDN, an internal CDN, etc. can extend via the filter |
| 17 |
$style_hosts = apply_filters( 'm_chart_iframe_csp_style_src', [ 'fonts.googleapis.com', 'use.typekit.net' ], $post->ID ); |
| 18 |
// Default allowed font-src hosts cover where Google Fonts + Adobe Typekit actually load their .woff2 files from |
| 19 |
$font_hosts = apply_filters( 'm_chart_iframe_csp_font_src', [ 'fonts.gstatic.com', 'use.typekit.net', 'p.typekit.net' ], $post->ID ); |
| 20 |
|
| 21 |
// Auto-extract hosts from anything passed through m_chart_iframe_styles |
| 22 |
// Site owners can add a stylesheet URL without also having to register its host in the CSP filter |
| 23 |
foreach ( $style_urls as $style_url ) { |
| 24 |
$host = wp_parse_url( $style_url, PHP_URL_HOST ); |
| 25 |
|
| 26 |
if ( $host ) { |
| 27 |
$style_hosts[] = $host; |
| 28 |
} |
| 29 |
} |
| 30 |
|
| 31 |
// Auto-extract hosts from m_chart_iframe_fonts font sources too |
| 32 |
foreach ( $font_descriptors as $font ) { |
| 33 |
foreach ( (array) ( $font['src'] ?? [] ) as $src ) { |
| 34 |
$host = wp_parse_url( $src['url'] ?? '', PHP_URL_HOST ); |
| 35 |
|
| 36 |
if ( $host ) { |
| 37 |
$font_hosts[] = $host; |
| 38 |
} |
| 39 |
} |
| 40 |
} |
| 41 |
|
| 42 |
$style_hosts = array_unique( array_filter( $style_hosts ) ); |
| 43 |
$font_hosts = array_unique( array_filter( $font_hosts ) ); |
| 44 |
|
| 45 |
// frame-ancestors controls who's allowed to embed this iframe page |
| 46 |
// Default 'self' permits same-origin embedding; site owners syndicating charts to third-party sites can add origins here |
| 47 |
$frame_ancestors = apply_filters( 'm_chart_iframe_frame_ancestors', [ "'self'" ], $post->ID ); |
| 48 |
|
| 49 |
// Generate per-response CSP nonce — cryptographically random, NOT a CSRF nonce (wp_create_nonce is the wrong tool here) |
| 50 |
$nonce = bin2hex( random_bytes( 16 ) ); |
| 51 |
|
| 52 |
// Whitelist the characters that can appear in a valid CSP source expression |
| 53 |
// esc_attr() is for HTML attributes — it would encode 'self' as 'self' here and break the directive |
| 54 |
// Stripping anything outside this character class prevents header injection (CRLF, semicolons) while preserving keywords/hosts/schemes |
| 55 |
$sanitize_csp_source = static function ( $value ) { |
| 56 |
return preg_replace( '/[^A-Za-z0-9.\-_:\/*\'+=@]/', '', (string) $value ); |
| 57 |
}; |
| 58 |
|
| 59 |
// style-src uses 'unsafe-inline' (not a nonce) because inline style="..." attributes can't carry a nonce |
| 60 |
// CSS-driven exfiltration vectors (url(), @font-face src, @import) are still blocked by font-src / img-src / connect-src |
| 61 |
// script-src keeps a nonce since the chart template emits a dynamic inline <script> we want strictly gated |
| 62 |
$csp = sprintf( |
| 63 |
"default-src 'none'; style-src 'self' 'unsafe-inline' %s; script-src 'self' 'nonce-%s'; font-src 'self' data: %s; img-src 'self' data:; connect-src 'self'; frame-ancestors %s;", |
| 64 |
implode( ' ', array_map( $sanitize_csp_source, $style_hosts ) ), |
| 65 |
$nonce, |
| 66 |
implode( ' ', array_map( $sanitize_csp_source, $font_hosts ) ), |
| 67 |
implode( ' ', array_map( $sanitize_csp_source, $frame_ancestors ) ) |
| 68 |
); |
| 69 |
|
| 70 |
header( 'Content-Security-Policy: ' . $csp ); |
| 71 |
|
| 72 |
// Expose the nonce to chartjs-chart.php so its inline <script> can carry a matching nonce attribute |
| 73 |
$this->iframe_csp_nonce = $nonce; |
| 74 |
?> |
| 75 |
<!doctype html> |
| 76 |
<html <?php language_attributes(); ?>> |
| 77 |
<head> |
| 78 |
<meta charset="utf-8"> |
| 79 |
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> |
| 80 |
<title><?php echo esc_html( get_the_title( $post->ID ) ); ?></title> |
| 81 |
<meta name="description" content=""> |
| 82 |
<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 83 |
<?php wp_print_scripts( apply_filters( 'm_chart_iframe_scripts', $scripts, $post->ID ) ); ?> |
| 84 |
<?php |
| 85 |
// Stylesheet URLs to inject into the iframe head |
| 86 |
// Used by extensions that need to load external CSS (e.g. Google Fonts, Adobe Typekit) inside the iframe |
| 87 |
// iframes don't inherit parent-page font/style loads |
| 88 |
foreach ( $style_urls as $style_url ) { |
| 89 |
printf( '<link rel="stylesheet" href="%s" />' . "\n", esc_url( $style_url ) ); |
| 90 |
} |
| 91 |
|
| 92 |
// Inline CSS strings to inject into the iframe head |
| 93 |
// Used by extensions that need to emit raw CSS — typically custom-uploaded @font-face rules where the producer's own sanitizer is the trust boundary |
| 94 |
// Distinct from m_chart_iframe_fonts below, which generates @font-face from validated field-by-field descriptors |
| 95 |
// Use this hook only when the CSS is already authored as CSS (e.g. m-chart-pro themes' customFontFace blocks) |
| 96 |
foreach ( $inline_styles as $css ) { |
| 97 |
if ( '' === trim( (string) $css ) ) { |
| 98 |
continue; |
| 99 |
} |
| 100 |
|
| 101 |
printf( "<style>%s</style>\n", $css ); |
| 102 |
} |
| 103 |
|
| 104 |
// Structured @font-face injection — render each descriptor through a known-safe template |
| 105 |
// Every slot is allowlisted: family is char-class-stripped, weight/style/display are enum-validated |
| 106 |
// Src URLs are esc_url'd with HTTPS-only scheme constraint, format is alnum-stripped |
| 107 |
foreach ( $font_descriptors as $font ) { |
| 108 |
$family = preg_replace( '/[^A-Za-z0-9 _-]/', '', (string) ( $font['family'] ?? '' ) ); |
| 109 |
|
| 110 |
if ( '' === $family ) { |
| 111 |
continue; |
| 112 |
} |
| 113 |
|
| 114 |
$weight = preg_match( '/^[1-9]00$|^(normal|bold)$/', (string) ( $font['weight'] ?? '400' ) ) ? $font['weight'] : '400'; |
| 115 |
$style = in_array( $font['style'] ?? 'normal', [ 'normal', 'italic', 'oblique' ], true ) ? $font['style'] : 'normal'; |
| 116 |
$display = in_array( $font['display'] ?? 'swap', [ 'auto', 'block', 'swap', 'fallback', 'optional' ], true ) ? $font['display'] : 'swap'; |
| 117 |
|
| 118 |
$srcs = []; |
| 119 |
|
| 120 |
foreach ( (array) ( $font['src'] ?? [] ) as $src ) { |
| 121 |
$url = esc_url( $src['url'] ?? '', [ 'https' ] ); |
| 122 |
|
| 123 |
if ( '' === $url ) { |
| 124 |
continue; |
| 125 |
} |
| 126 |
|
| 127 |
$format = preg_replace( '/[^a-z0-9-]/', '', strtolower( (string) ( $src['format'] ?? '' ) ) ); |
| 128 |
$srcs[] = sprintf( "url('%s')%s", $url, $format ? " format('{$format}')" : '' ); |
| 129 |
} |
| 130 |
|
| 131 |
if ( empty( $srcs ) ) { |
| 132 |
continue; |
| 133 |
} |
| 134 |
|
| 135 |
printf( |
| 136 |
"<style>@font-face { font-family: '%s'; src: %s; font-weight: %s; font-style: %s; font-display: %s; }</style>\n", |
| 137 |
esc_html( $family ), |
| 138 |
implode( ', ', $srcs ), |
| 139 |
esc_html( $weight ), |
| 140 |
esc_html( $style ), |
| 141 |
esc_html( $display ) |
| 142 |
); |
| 143 |
} |
| 144 |
?> |
| 145 |
<style> |
| 146 |
/* This prevents the screenreader stuff from being visible just like it behaves in it's regular context in the site */ |
| 147 |
.screen-reader-text { |
| 148 |
border: 0; |
| 149 |
clip-path: inset(50%); |
| 150 |
height: 1px; |
| 151 |
margin: -1px; |
| 152 |
overflow: hidden; |
| 153 |
padding: 0; |
| 154 |
position: absolute; |
| 155 |
width: 1px; |
| 156 |
word-wrap: normal !important; |
| 157 |
} |
| 158 |
|
| 159 |
/* Un-hide the source attribution while its link has keyboard focus, mirrors m-chart-frontend.scss */ |
| 160 |
.m-chart-source.screen-reader-text:focus-within { |
| 161 |
position: static; |
| 162 |
width: auto; |
| 163 |
height: auto; |
| 164 |
margin: 0; |
| 165 |
overflow: visible; |
| 166 |
clip-path: none; |
| 167 |
} |
| 168 |
|
| 169 |
/* Strip user-agent margin from <figure> so the chart fills the iframe edge-to-edge */ |
| 170 |
body, figure { margin: 0; } |
| 171 |
</style> |
| 172 |
</head> |
| 173 |
<!-- overflow: hidden; prevents the iframe from scrolling --> |
| 174 |
<body style="overflow: hidden;"> |
| 175 |
<?php |
| 176 |
$args = array_intersect_key( $_GET, $this->get_chart_default_args ); |
| 177 |
$args = array_map( 'sanitize_text_field', $args ); |
| 178 |
echo $this->get_chart( $post->ID, $args ); |
| 179 |
?> |
| 180 |
</body> |
| 181 |
</html> |
| 182 |
|