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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ hostile:
checked by content, not by the server's `Content-Type`, and only raster
formats are stored; SVG stays remote. Images are served only to the
logged-in owner, with `nosniff`.
- **Content-Security-Policy** on every page, as a second lock behind the
sanitiser: scripts only from this origin or carrying the per-request
nonce, no `unsafe-inline` or `unsafe-eval`, no inline event handlers or
style attributes, nothing framing the app, forms posting only here.
Alpine is its CSP build, so components live in `resources/js` rather
than inline in the markup; add behaviour there (or as a `data-*` hook in
`resources/js/behaviours.js`), never as an `onclick` attribute. Images
may come from any `https:` host, because undownloaded article images
stay remote. `npm run dev` is allowed automatically while it runs.
- The bookmarklet and share target are `GET` requests, because that is
how they arrive. A third-party page could link you to `/save?url=…`; the
worst that does is add a link to your own list.
Expand Down
90 changes: 90 additions & 0 deletions app/Http/Middleware/ContentSecurityPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;

/**
* A strict Content-Security-Policy on every web page.
*
* The reader prints third-party article HTML unescaped. The sanitiser is
* what keeps scripts out of it; this is the second lock: even if markup
* slipped past the sanitiser, the browser would refuse to run inline
* scripts, inline event handlers, javascript: URLs or code from any other
* origin.
*
* - Scripts: this origin, plus inline <script> tags carrying the
* per-request nonce (Vite::useCspNonce() stamps it on the built assets
* too). No 'unsafe-inline', no 'unsafe-eval': Alpine is the CSP build.
* - Styles and fonts: this origin and the Bunny Fonts stylesheet. No
* inline style attributes.
* - Images: this origin, plus any https host, because article images that
* weren't downloaded keep their remote src.
* - Nothing may frame the app from elsewhere, and forms only post here.
*
* Responses that already carry a policy (stored article images send
* "default-src 'none'") keep their own.
*/
class ContentSecurityPolicy
{
public const FONT_HOST = 'https://fonts.bunny.net';

/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$nonce = Vite::useCspNonce();

$response = $next($request);

if (! $response->headers->has('Content-Security-Policy')) {
$response->headers->set('Content-Security-Policy', $this->policy($nonce));
}

return $response;
}

protected function policy(string $nonce): string
{
$script = ["'self'", "'nonce-{$nonce}'"];
$style = ["'self'", self::FONT_HOST];
$connect = ["'self'"];

// `npm run dev`: assets come from the Vite dev server, which also
// injects CSS through <style> tags and talks over a websocket.
if (Vite::isRunningHot()) {
$hot = rtrim(trim((string) file_get_contents(Vite::hotFile())), '/');
$socket = (string) preg_replace('#^http#', 'ws', $hot);

$script[] = $hot;
$style[] = $hot;
$style[] = "'unsafe-inline'";
$connect[] = $hot;
$connect[] = $socket;
}

$directives = [
'default-src' => ["'none'"],
'script-src' => $script,
'style-src' => $style,
'font-src' => ["'self'", self::FONT_HOST],
'img-src' => ["'self'", 'https:', 'data:'],
'connect-src' => $connect,
'manifest-src' => ["'self'"],
'worker-src' => ["'self'"],
'form-action' => ["'self'"],
'frame-ancestors' => ["'self'"],
'base-uri' => ["'none'"],
];

return collect($directives)
->map(fn (array $sources, string $directive): string => $directive.' '.implode(' ', $sources))
->implode('; ');
}
}
5 changes: 3 additions & 2 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use App\Http\Middleware\ContentSecurityPolicy;
use App\Http\Middleware\EnsureNotInstalled;
use App\Http\Middleware\SecurityHeaders;
use Illuminate\Foundation\Application;
Expand All @@ -18,12 +19,12 @@
// The web installer runs before the app is usable — the
// database (and with it the session store) may not exist yet —
// so its routes deliberately skip the 'web' middleware group.
Route::middleware([EnsureNotInstalled::class, SecurityHeaders::class])
Route::middleware([EnsureNotInstalled::class, SecurityHeaders::class, ContentSecurityPolicy::class])
->group(base_path('routes/install.php'));
},
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: SecurityHeaders::class);
$middleware->web(append: [SecurityHeaders::class, ContentSecurityPolicy::class]);
$middleware->api(append: SecurityHeaders::class);
})
->withExceptions(function (Exceptions $exceptions): void {
Expand Down
22 changes: 11 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"dev": "vite"
},
"devDependencies": {
"@alpinejs/csp": "^3.17.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/vite": "^4.3.3",
"alpinejs": "^3.16.2",
"concurrently": "^10.0.5",
"laravel-vite-plugin": "^3.2",
"tailwindcss": "^4.3.3",
Expand Down
15 changes: 15 additions & 0 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,18 @@
padding: 0.4em 0.6em;
text-align: left;
}

/* Reader controls that follow the theme (no inline style attributes: the CSP forbids them). */
.reader-border {
border-color: var(--reader-rule);
}

.reader-swatch-sepia {
background: #f6efe0;
color: #4a3b2a;
}

.reader-swatch-dark {
background: #121417;
color: #d6d9de;
}
11 changes: 10 additions & 1 deletion resources/js/app.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import Alpine from 'alpinejs';
// The CSP build of Alpine: it evaluates directive expressions with its own
// parser instead of new Function(), so the Content-Security-Policy needs
// no 'unsafe-eval'. Anything with real logic lives in a registered
// component below rather than inline in the markup.
import Alpine from '@alpinejs/csp';
import behaviours from './behaviours';
import bulkSelection from './bulk-selection';
import reader from './reader';

window.Alpine = Alpine;

Alpine.data('bulkSelection', bulkSelection);
Alpine.data('reader', reader);

behaviours(document);

Alpine.start();
43 changes: 43 additions & 0 deletions resources/js/behaviours.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Small page behaviours that used to be inline onclick/onsubmit/onchange
* attributes, which the Content-Security-Policy blocks. Each is opted into
* with a data attribute and handled by one delegated listener.
*
* <form data-confirm="Delete this?"> ask before submitting
* <select data-autosubmit> submit the form on change
* <input data-select-on-click> select the text when clicked
* <a data-bookmarklet-hint="Drag me…"> explain instead of navigating
*/
export default function behaviours(root) {
root.addEventListener('submit', (event) => {
const form = event.target;

if (form instanceof HTMLFormElement && form.dataset.confirm && !window.confirm(form.dataset.confirm)) {
event.preventDefault();
event.stopImmediatePropagation();
}
}, true);

root.addEventListener('change', (event) => {
const field = event.target;

if (field instanceof HTMLSelectElement && field.hasAttribute('data-autosubmit') && field.form) {
field.form.requestSubmit();
}
});

root.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target : null;

const selectable = target?.closest('[data-select-on-click]');
if (selectable instanceof HTMLInputElement || selectable instanceof HTMLTextAreaElement) {
selectable.select();
}

const bookmarklet = target?.closest('[data-bookmarklet-hint]');
if (bookmarklet instanceof HTMLElement) {
event.preventDefault();
window.alert(bookmarklet.dataset.bookmarkletHint);
}
});
}
51 changes: 51 additions & 0 deletions resources/js/bulk-selection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Ticking items in a list and applying a bulk action. The item IDs on the
* page and the delete confirmation come in as data attributes on the
* component's root element:
*
* <div x-data="bulkSelection" data-ids='["1","2"]' data-confirm-delete="…">
*
* IDs are strings because checkbox values are.
*/
export default () => ({
selected: [],
ids: [],
action: '',
confirmDelete: '',

init() {
try {
this.ids = JSON.parse(this.$el.dataset.ids || '[]').map(String);
} catch {
this.ids = [];
}

this.confirmDelete = this.$el.dataset.confirmDelete || 'Delete the selected items?';
},

get all() {
return this.ids.length > 0 && this.selected.length === this.ids.length;
},

get count() {
return this.selected.length;
},

get countLabel() {
return `${this.selected.length} selected`;
},

get tagging() {
return this.action === 'tag';
},

toggleAll() {
this.selected = this.all ? [] : [...this.ids];
},

submit(event) {
if (this.action === 'delete' && !window.confirm(this.confirmDelete)) {
event.preventDefault();
}
},
});
20 changes: 17 additions & 3 deletions resources/js/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,25 @@ export function applyPreferences(prefs, root = document.documentElement) {
root.style.setProperty('--reader-size', `${prefs.size}px`);
}

export default (config) => ({
/**
* Registered as Alpine.data('reader'). The progress endpoint and the saved
* position come in as data attributes on the root element (the CSP build
* can't take a config object built in the markup):
*
* <body x-data="reader" data-progress-url="…" data-progress="0.42">
*/
export default () => ({
prefs: loadPreferences(),
panel: false,
progressUrl: config.progressUrl,
savedProgress: Number(config.progress) || 0,
progressUrl: '',
savedProgress: 0,
lastSent: null,
timer: null,

init() {
this.progressUrl = this.$el.dataset.progressUrl || '';
this.savedProgress = Number(this.$el.dataset.progress) || 0;

applyPreferences(this.prefs);

this.$watch('prefs', (prefs) => {
Expand Down Expand Up @@ -89,6 +99,10 @@ export default (config) => ({
},

send(leaving) {
if (!this.progressUrl) {
return;
}

const progress = Math.round(this.progress() * 10000) / 10000;

if (this.lastSent !== null && Math.abs(progress - this.lastSent) < 0.005) {
Expand Down
1 change: 0 additions & 1 deletion resources/views/components/dropdown-link.blade.php

This file was deleted.

35 changes: 0 additions & 35 deletions resources/views/components/dropdown.blade.php

This file was deleted.

Loading
Loading