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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to `ichava/icon-browser` follow [Keep a Changelog](https://k

## [Unreleased]

### Fixed

- **The browser page works under a host's nonce-based CSP.** A host that sends its own policy with a per-request nonce (`script-src 'self' 'nonce-…'`, as ichava.com does) hands that nonce to Vite; the page now puts it on the loader's `<style>` and `<script>`, the frame `<style>`, the stylesheet, the module scripts and the dev-server scripts, and writes it to a `csp-nonce` meta tag for the styles React Aria injects at runtime. Before, those blocks carried no nonce and the policy blocked them. Without a host nonce, the package's `nonce` CSP mode supplies core's, as before. `Support\PageNonce` resolves which; `HostCspNonceTest` and `PrepaintTest` guard it.

### Removed

- `CreateCollection::rules()`: its two callers now use `StoreCollectionRequest`, which owns the rules (forward only).
Expand Down
12 changes: 12 additions & 0 deletions docs/tools/host-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ The browser page reads both props: `browserConfig` (`resources/js/core/appConfig

`routes.middleware` is appended to every browser route group and `routes.domain` mounts the browser on one domain. The `/{prefix}` redirect exists only while the Inertia pages are on.

## Content Security Policy

The browser page writes a few inline blocks: the loader's `<style>` and `<script>`, a frame `<style>`, and, in dev mode, the Vite client scripts. A host whose policy allows inline blocks only by nonce hands its per-request nonce to Vite, as Laravel does for its own tags:

```php
// in the host's CSP middleware, before the response renders
$nonce = app(\Illuminate\Foundation\Vite::class)->useCspNonce();
// ... then send script-src 'self' 'nonce-{$nonce}' and style-src 'self' 'nonce-{$nonce}'
```

The page then puts that nonce on every inline block, the stylesheet and the module scripts, and writes `<meta name="csp-nonce">` for the styles React Aria injects at runtime. A host with no nonce of its own can use the package's `nonce` CSP mode (`ICHAVA_CSP_MODE=nonce`), and the page uses core's nonce instead. The bundle loads nothing from another origin, draws icons from this host's API and `data:` URIs, so `'self'` plus the nonce and `img-src 'self' data:` cover it.

## Installing

```bash
Expand Down
2 changes: 1 addition & 1 deletion public/assets/js/inertia-app.js

Large diffs are not rendered by default.

22 changes: 16 additions & 6 deletions resources/views/app.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
@php
$pageNonce = app(\Simtabi\Laranail\Ichava\IconBrowser\Support\PageNonce::class)->value();
@endphp
@if($pageNonce !== null)
{{-- The host's or the package's CSP nonce: React Aria reads it here for the styles it injects. --}}
<meta name="csp-nonce" content="{{ $pageNonce }}">
@endif

<title inertia>{{ config('app.name', 'Ichava') }}</title>

Expand All @@ -29,22 +36,22 @@
@endphp

@if($viteDev)
<script type="module">
<script type="module" @if($pageNonce !== null) nonce="{{ $pageNonce }}" @endif>
import RefreshRuntime from '{{ $viteBase }}/@react-refresh';
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
</script>
<script type="module" src="{{ $viteClient }}"></script>
<script type="module" src="{{ $viteBase }}/resources/js/app.tsx"></script>
<script type="module" src="{{ $viteClient }}" @if($pageNonce !== null) nonce="{{ $pageNonce }}" @endif></script>
<script type="module" src="{{ $viteBase }}/resources/js/app.tsx" @if($pageNonce !== null) nonce="{{ $pageNonce }}" @endif></script>
@else
<link rel="stylesheet" href="{{ asset('vendor/ichava/assets/css/inertia-app.css') }}?v={{ Helpers::assetVersion('vendor/ichava/assets/css/inertia-app.css') }}">
<link rel="stylesheet" href="{{ asset('vendor/ichava/assets/css/inertia-app.css') }}?v={{ Helpers::assetVersion('vendor/ichava/assets/css/inertia-app.css') }}" @if($pageNonce !== null) nonce="{{ $pageNonce }}" @endif>
@endif

@inertiaHead

<style>
<style @if($pageNonce !== null) nonce="{{ $pageNonce }}" @endif>
#app { display: flex; flex-direction: column; flex: 1 0 auto; min-height: 100dvh; }
</style>
</head>
Expand All @@ -58,7 +65,10 @@
</div>

@if(! $viteDev)
<script src="{{ asset('vendor/ichava/assets/js/inertia-app.js') }}?v={{ Helpers::assetVersion('vendor/ichava/assets/js/inertia-app.js') }}" type="module"></script>
{{-- No ?v= on the entry: its page chunks import it back as `./inertia-app.js`, and a module
URL with a query is a different module. Two copies of the entry meant two Inertia
contexts, so every page threw "usePage must be used within the Inertia component". --}}
<script src="{{ asset('vendor/ichava/assets/js/inertia-app.js') }}" type="module" @if($pageNonce !== null) nonce="{{ $pageNonce }}" @endif></script>
@endif
</body>
</html>
49 changes: 49 additions & 0 deletions src/Support/PageNonce.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Simtabi\Laranail\Ichava\IconBrowser\Support;

use Illuminate\Foundation\Vite;
use Simtabi\Laranail\Ichava\Support\SecurityNonce;

/**
* The CSP nonce the browser page puts on its own inline blocks (the loader, the frame style,
* the dev-server scripts) and in its `csp-nonce` meta tag, which React Aria reads for the
* styles it injects at runtime.
*
* A host that sends its own nonce-based policy hands its nonce to Vite
* (`Vite::useCspNonce()`, Laravel's own convention), so that one wins: it is the value in the
* header the browser checks. Without one, the package's `nonce` CSP mode supplies core's
* per-request nonce, as before. Otherwise there is none, and the page writes no nonce at all.
*/
final class PageNonce
{
public function __construct(
private readonly Vite $vite,
private readonly SecurityNonce $nonce,
) {}

public function value(): ?string
{
$host = $this->vite->cspNonce();

if (is_string($host) && $host !== '') {
return $host;
}

if (config('ichava.icon-browser.security.csp.mode', 'strict') === 'nonce') {
return $this->nonce->value();
}

return null;
}

/** ` nonce="…"`, ready to put after a tag name, or '' when the page has no nonce. */
public function attribute(): string
{
$value = $this->value();

return $value === null ? '' : ' nonce="' . e($value) . '"';
}
}
9 changes: 5 additions & 4 deletions src/View/Components/Prepaint.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use Illuminate\View\Component;
use Illuminate\Support\HtmlString;
use Simtabi\Laranail\Ichava\Support\SecurityNonce;
use Simtabi\Laranail\Ichava\IconBrowser\Support\PageNonce;

/**
* Inline react-icon-browser's static loader, so something paints on the first HTML parse
Expand All @@ -22,7 +22,7 @@
*/
final class Prepaint extends Component
{
public function __construct(private readonly SecurityNonce $nonce) {}
public function __construct(private readonly PageNonce $nonce) {}

public static function path(): string
{
Expand All @@ -33,8 +33,9 @@ public function render(): HtmlString
{
$html = is_file(self::path()) ? (string) file_get_contents(self::path()) : '';

if ($html !== '' && config('ichava.icon-browser.security.csp.mode', 'strict') === 'nonce') {
$attribute = $this->nonce->attribute();
$attribute = $this->nonce->attribute();

if ($html !== '' && $attribute !== '') {
$html = str_replace(['<style>', '<script>'], ["<style{$attribute}>", "<script{$attribute}>"], $html);
}

Expand Down
51 changes: 51 additions & 0 deletions tests/Feature/HostCspNonceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

use Illuminate\Foundation\Vite;

// A host with its own nonce-based CSP (ichava.com sends `script-src 'self' 'nonce-…'`,
// `style-src 'self' 'nonce-…'`) hands its nonce to Vite. Every inline block the browser page
// writes must carry that nonce, or the policy blocks the loader and the frame style, and the
// meta tag must carry it for the styles React Aria injects at runtime.

it('puts the host nonce on every inline block of the browser page and in its csp-nonce meta', function (): void {
app(Vite::class)->useCspNonce('host-nonce-abcdef123456');

$html = $this->get('/ichava/icons')->assertOk()->getContent();

expect($html)->toContain('<meta name="csp-nonce" content="host-nonce-abcdef123456">');

// every <style> and every <script> that is not a JSON data block carries it
preg_match_all('#<(style|script)\b([^>]*)>#i', (string) $html, $tags, PREG_SET_ORDER);
$checked = 0;
foreach ($tags as [$tag, $name, $attributes]) {
if (str_contains($attributes, 'application/json')) {
continue;
}
$checked++;
$this->assertStringContainsString('nonce="host-nonce-abcdef123456"', $attributes, "{$tag} has no nonce");
}
expect($checked)->toBeGreaterThanOrEqual(3);
});

it('writes no csp-nonce meta when the host sends no nonce and the mode is strict', function (): void {
config(['ichava.icon-browser.security.csp.mode' => 'strict']);

$this->get('/ichava/icons')->assertOk()->assertDontSee('name="csp-nonce"', false);
});

it('loads the entry module at the URL its page chunks import it from, so there is one copy', function (): void {
// The page chunks import `./inertia-app.js` with no query. A `?v=` on the entry made the
// browser load it twice, as two modules with two Inertia contexts, and every page threw
// "usePage must be used within the Inertia component".
$chunks = glob(dirname(__DIR__, 2) . '/public/assets/js/inertia-app-*.js') ?: [];
expect($chunks)->not->toBeEmpty();
$importers = array_filter($chunks, fn (string $file): bool => str_contains((string) file_get_contents($file), '"./inertia-app.js"'));
expect($importers)->not->toBeEmpty();

$html = $this->get('/ichava/icons')->assertOk()->getContent();
preg_match('#<script src="([^"]*inertia-app\.js[^"]*)"#', (string) $html, $entry);

expect($entry[1] ?? null)->toEndWith('/vendor/ichava/assets/js/inertia-app.js');
});
18 changes: 18 additions & 0 deletions tests/Feature/PrepaintTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,21 @@

$this->assertStringStartsWith('<x-ichava-icon-browser::prepaint />', ltrim((string) $firstTag), 'the loader is the first child of <body>');
});

it('takes the host nonce, the one Vite carries, over the package mode', function (): void {
config(['ichava.icon-browser.security.csp.mode' => 'strict']);
app(Illuminate\Foundation\Vite::class)->useCspNonce('host-nonce-1234567890');

$html = Blade::render('<x-ichava-icon-browser::prepaint />');

$this->assertStringContainsString('<style nonce="host-nonce-1234567890">', $html);
$this->assertStringContainsString('<script nonce="host-nonce-1234567890">', $html);
});

it('writes no nonce when neither the host nor the package mode supplies one', function (): void {
config(['ichava.icon-browser.security.csp.mode' => 'strict']);

$html = Blade::render('<x-ichava-icon-browser::prepaint />');

$this->assertStringNotContainsString('nonce=', $html);
});
Loading