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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ The `docs/` directory contains **what** applies to this project:
- `docs/releasing.md` - Version scheme and release process
- `docs/sitemap.md` - XML sitemap module, coverage and generation
- `docs/seo.md` - Meta tags, social share cards and structured data
- `docs/csp.md` - The content security policy, its nonce and its derived script hash
- `docs/related-content.md` - Related-content lists and topic pages
- `docs/performance.md` - Image styles, self-hosted fonts and layout stability
- `docs/preview-links.md` - sharing unpublished content by link
Expand Down
39 changes: 39 additions & 0 deletions docs/csp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Content security policy

This document describes **what** the policy admits on this site and why. The module that emits it is [`csp`](https://www.drupal.org/project/csp).

## Where the policy lives

`config/default/csp.settings.yml` holds the enforced directives and their source lists. Two sources are not in that file, because they are per-response and are attached by `PageAttachmentsHook` instead: a nonce and a hash.

## Inline scripts

`script-src` carries no `unsafe-inline`, so an inline `<script>` runs only if the policy names it.

- **The nonce** is generated per response by the `csp` module. Every response asks for one, which is what puts `nonce-{value}` in the directive. Nothing but the module ever writes it.
- **The hash** covers the inline script that core's navigation toolbar renders to set the admin sidebar's state before first paint. That script carries no nonce, so a hash is the only source that admits it. Without it the script is blocked, the console reports the violation, and editors see the sidebar flash from collapsed to expanded on every admin page.

A hash is not a secret: it is a digest of code that ships in Drupal core, it is published in the response header on every request, and it admits exactly one byte-identical script and nothing else. What must stay unpredictable is the nonce, and that never leaves the request it was made for.

## The hash is derived, not recorded

`NavigationScriptHash` reads `core/modules/navigation/layouts/navigation.html.twig`, hashes each inline script it holds, and caches the result. Nothing digest-like is committed, so a core release that edits that script cannot leave a stale value behind.

It logs a warning to the `do_base` channel and returns no hash when it cannot account for a script:

| Warning | Meaning |
|---|---|
| `... is not readable` | The template moved or the module is gone. Check whether core still renders a toolbar script at all. |
| `... holds no inline script this can read` | Core restructured the template. If it now renders the script with a nonce, delete the hash attachment; otherwise adjust the extraction. |
| `... carries Twig syntax` | Core made the script dynamic, so its rendered bytes cannot be known from the template. A nonce is the only remaining option, which means asking core for one. |

`PageAttachmentsTest::testAllowedHashMatchesTheTemplate()` asserts the derivation still reads the template core currently ships, so any of the above fails in continuous integration rather than only appearing in a log.

## Adding an external source

Add the host to the matching directive in `config/default/csp.settings.yml` and export. Prefer self-hosting the asset: the fonts this site serves were moved off `fonts.googleapis.com` for that reason, and the policy has had no Google Fonts source since. See [Front-end performance](performance.md#fonts-are-served-from-this-origin).

## Related

- [Front-end performance](performance.md) - self-hosted fonts, and the editor stylesheets the policy would otherwise block
- [SEO](seo.md) - meta tags, share cards and structured data
8 changes: 7 additions & 1 deletion docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Checking the markup is not enough to catch this, because the markup is correct.

Lexend and Rubik ship in `assets/fonts/` and are declared in `components/00-base/fonts/fonts.scss`. Nothing is fetched from `fonts.googleapis.com`, which is why the CSP no longer allows it.

The editing area needs its own arrangement to hold that line. CKEditor 5 merges the base theme's `ckeditor5-stylesheets` into this theme's list, `.info.yml` has no override for that key, and CivicTheme's build of the editor stylesheet opens with two `@import` statements pointing at Google Fonts. `LibraryInfoAlterHook` drops the base theme's files from that list; `dist/styles.editor.css` is this theme's build of the same partials, with the self-hosted faces in place of the imports, and contributes every selector the base theme's copy did.

Leaving those imports in place cost more than a blocked request. A stylesheet whose `@import` is blocked fires `error` rather than `load`, so an Ajax response that attached the editor stylesheets reported the aggregate as unloadable and abandoned the commands queued behind it.

They are declared by hand rather than through CivicTheme's `$ct-fonts` map, because the generator that map feeds emits one `@font-face` per weight and supports neither the variable weight ranges these faces ship as nor the `unicode-range` and `font-display` descriptors a self-hosted face needs. The map still names the families, with an empty `types` list so it emits nothing.

Only the Latin subsets are preloaded, by `_drevops_attach_font_preloads()`. The extended subsets are needed by a small minority of pages, and a preload the page does not use is a wasted request.
Expand All @@ -62,7 +66,9 @@ Replacing a face means replacing the `woff2` files and the `unicode-range` value

The banner paints its background from CSS, so the browser cannot discover the file until the stylesheet has been fetched and parsed. On the pages carrying one, that background is the largest contentful paint.

`_do_base_attach_banner_preload()` emits a `rel="preload"` for it. The URL has to be the one the stylesheet asks for, or the file is fetched twice: the preload resolves the same image style, and skips the whole thing when the file has no derivative.
`PageAttachmentsHook` emits a `rel="preload"` for it. The URL has to be the one the stylesheet asks for, or the file is fetched twice: the preload resolves the same image style, and skips the whole thing when the file has no derivative.

It runs only on the routes listed in that hook's `BANNER_ROUTES`, plus any route carrying the `_preview_link_route` option, which are the pages that draw a banner: the canonical route, a revision, the latest version, and a preview link. The edit form, the delete confirmation and the revision list all carry a node parameter and resolve the same background without ever rendering it, and a preload the page does not use is a wasted request for a full-width derivative.

## Verifying a change

Expand Down
121 changes: 0 additions & 121 deletions web/modules/custom/do_base/do_base.module
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@

declare(strict_types=1);

use Drupal\csp\Csp;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Site\Settings;
use Drupal\file\FileInterface;
use Drupal\image\Entity\ImageStyle;
use Drupal\media\MediaInterface;

/**
* Implements hook_mail_alter().
Expand All @@ -26,122 +21,6 @@ function do_base_mail_alter(array &$message): void {
}
}

/**
* Implements hook_page_attachments().
*/
function do_base_page_attachments(array &$attachments): void {
_do_base_attach_preview_link_robots($attachments);
_do_base_attach_csp_nonce($attachments);
_do_base_attach_banner_preload($attachments);
}

/**
* Preloads the banner background image.
*
* The banner paints its background from CSS, so the browser cannot discover the
* file until the stylesheet has been fetched and parsed.
*/
function _do_base_attach_banner_preload(array &$attachments): void {
$url = _do_base_banner_background_url();

if ($url === NULL) {
return;
}

$attachments['#attached']['html_head_link'][] = [
[
'rel' => 'preload',
'href' => $url,
'as' => 'image',
'fetchpriority' => 'high',
],
];
}

/**
* Resolves the styled URL of the current node's banner background.
*
* @return string|null
* The URL, or NULL when there is no node, no background, or the file cannot
* be processed into a derivative.
*/
function _do_base_banner_background_url(): ?string {
$route_match = \Drupal::routeMatch();
$node = $route_match->getParameter('node_revision') ?: $route_match->getParameter('node');

if (!$node instanceof FieldableEntityInterface || !$node->hasField('field_c_n_banner_background')) {
return NULL;
}

$media = $node->get('field_c_n_banner_background')->entity;
if (!$media instanceof MediaInterface) {
return NULL;
}

$source_field = $media->getSource()->getConfiguration()['source_field'] ?? NULL;
if (empty($source_field) || !$media->hasField($source_field)) {
return NULL;
}

$file = $media->get($source_field)->entity;
if (!$file instanceof FileInterface) {
return NULL;
}

$style = ImageStyle::load('banner_background');
// A vector has no derivative, so the stylesheet requests the original and a
// preload of anything else would fetch the file twice.
if (!$style instanceof ImageStyle || !$style->supportsUri($file->getFileUri())) {
return NULL;
}

return $style->buildUrl($file->getFileUri());
}

/**
* Keeps preview link pages out of search indexes.
*/
function _do_base_attach_preview_link_robots(array &$attachments): void {
$route = \Drupal::routeMatch()->getRouteObject();

if ($route === NULL || $route->getOption('_preview_link_route') !== TRUE) {
return;
}

// A preview link renders unpublished content to anyone holding the token,
// and the URL is meant to be pasted into mail and chat clients that follow
// links, so the page must never reach a search index.
$attachments['#attached']['html_head'][] = [
[
'#tag' => 'meta',
'#attributes' => [
'name' => 'robots',
'content' => 'noindex, nofollow',
],
],
'do_base_preview_link_robots',
];
}

/**
* Attaches a CSP nonce so core's inline scripts survive a strict policy.
*/
function _do_base_attach_csp_nonce(array &$attachments): void {
if (!class_exists(Csp::class)) {
return;
}

// The 'unsafe-inline' fallback is only used by browsers without CSP3 nonce
// support; modern browsers ignore it once a nonce is present.
$existing = $attachments['#attached']['csp_nonce']['script'] ?? [];
$attachments['#attached']['csp_nonce']['script'] = array_values(array_unique(array_merge($existing, [Csp::POLICY_UNSAFE_INLINE])));

$libraries = $attachments['#attached']['library'] ?? [];
if (!in_array('csp/nonce', $libraries, TRUE)) {
$attachments['#attached']['library'][] = 'csp/nonce';
}
}

/**
* Implements hook_xmlsitemap_link_alter().
*/
Expand Down
5 changes: 5 additions & 0 deletions web/modules/custom/do_base/do_base.services.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
services:
logger.channel.do_base:
parent: logger.channel_base
arguments: ['do_base']
Drupal\do_base\NavigationScriptHash:
arguments: ['@extension.list.module', '@cache.default', '@logger.channel.do_base', '%app.root%']
Drupal\do_base\Hook\AutomatedListPagerHook:
arguments: ['@request_stack', '@pager.manager']
do_base.twig.icon_assets:
Expand Down
54 changes: 49 additions & 5 deletions web/modules/custom/do_base/src/Hook/LibraryInfoAlterHook.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,68 @@

namespace Drupal\do_base\Hook;

use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Hook\Order\OrderAfter;

/**
* Library info alter hooks for do_base module.
*/
final class LibraryInfoAlterHook {
final readonly class LibraryInfoAlterHook {

/**
* Library holding the stylesheets CKEditor 5 loads for the editing area.
*/
private const string EDITOR_STYLESHEETS = 'internal.drupal.ckeditor5.stylesheets';

/**
* Theme whose editor stylesheets the sub-theme rebuilds.
*/
private const string BASE_THEME = 'civictheme';

public function __construct(
protected ThemeExtensionList $themeExtensionList,
) {
}

/**
* Implements hook_library_info_alter().
*
* Attaches Gherkin language support whenever Highlight.js is loaded.
* The CDN common bundle does not include Gherkin, so we load it separately.
*/
#[Hook('library_info_alter')]
#[Hook('library_info_alter', order: new OrderAfter(modules: ['ckeditor5']))]
public function alter(array &$libraries, string $extension): void {
if ($extension === 'highlight_js' && isset($libraries['highlight_js.custom'])) {
// The CDN common bundle carries no Gherkin grammar, so it is loaded
// separately.
$libraries['highlight_js.custom']['dependencies'][] = 'do_base/highlight_js.gherkin';
}

if ($extension === 'ckeditor5' && isset($libraries[self::EDITOR_STYLESHEETS]['css']['theme'])) {
// The base theme's stylesheets are merged into this list and .info.yml
// cannot override them. Its build imports Google Fonts, which the policy
// blocks, and a blocked @import fails the whole stylesheet.
$libraries[self::EDITOR_STYLESHEETS]['css']['theme'] = $this->withoutBaseTheme($libraries[self::EDITOR_STYLESHEETS]['css']['theme']);
}
}

/**
* Filters out stylesheets that belong to the base theme.
*
* @param array<string, array<string, mixed>> $stylesheets
* Stylesheets keyed by path. A theme's own files are keyed from the
* docroot, with a leading slash; the key can also be an external URL or a
* path into the files directory.
*
* @return array<string, array<string, mixed>>
* The stylesheets that are not files of the base theme.
*/
protected function withoutBaseTheme(array $stylesheets): array {
if (!$this->themeExtensionList->exists(self::BASE_THEME)) {
return $stylesheets;
}

$base_theme_path = $this->themeExtensionList->getPath(self::BASE_THEME) . '/';

return array_filter($stylesheets, static fn(string $path): bool => !str_starts_with(ltrim($path, '/'), $base_theme_path), ARRAY_FILTER_USE_KEY);
}

}
Loading
Loading