DEV Community

Cover image for Your Package's CDN Link Is Your Host App's CSP Violation
Nasrul Hazim
Nasrul Hazim

Posted on

Your Package's CDN Link Is Your Host App's CSP Violation

TL;DR — A Laravel package that fetches a stylesheet, a script, or a webfont from a third-party host isn't shipping a UI. It's shipping a policy requirement that every host application has to satisfy. And since CSP is usually enabled in production only, that requirement is invisible on your machine and non-negotiable on theirs.


The bug that only exists in production

laravel-artisan-runner gives you a web UI for running whitelisted Artisan commands. Its layout used to start like this:

<script src="https://cdn.tailwindcss.com"></script>
<script>
    tailwind.config = {
        darkMode: 'media',
        theme: { extend: { fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] } } }
    }
</script>
Enter fullscreen mode Exit fullscreen mode

Works beautifully. On my machine. On yours. On every reviewer's machine.

Then it shipped into a host application that sets script-src 'self' in production — and only in production, because a strict CSP locally would break the Vite dev server. The browser refused the CDN script, and the console said so:

Loading the script 'https://cdn.tailwindcss.com/' violates the following
Content Security Policy directive: "script-src 'self' …"
Enter fullscreen mode Exit fullscreen mode

The failure mode was not "slightly unstyled". Tailwind's browser build is what sizes everything, so the header logo — constrained only by h-9 w-9 — rendered at its natural SVG size and filled the viewport. And the inline tailwind.config = {...} immediately after the blocked script threw ReferenceError: tailwind is not defined, taking the rest of that script block with it.

One refused request, two visible failures, zero of them reproducible in dev.

Fix one: ship the stylesheet

The real answer is that the package's UI should be styled by a file the package owns:

@if (file_exists(public_path('vendor/artisan-runner/artisan-runner.css')))
    <link rel="stylesheet" href="{{ asset('vendor/artisan-runner/artisan-runner.css') }}">
@else
    <style>{!! file_get_contents(ArtisanRunnerServiceProvider::DIST_PATH.'/artisan-runner.css') !!}</style>
@endif
Enter fullscreen mode Exit fullscreen mode

The two-branch pattern matters. vendor:publish is a thing the host application has to remember to do, and a package UI that renders naked because someone skipped a publish step is a support ticket, not a feature. So: use the published file if it's there, inline the compiled one from the package if it isn't. The logos in this package already worked that way; the stylesheet just joined them.

Worth naming out loud: cdn.tailwindcss.com is documented by Tailwind as development-only. It compiles Tailwind in the browser on every page load. Even without CSP, it makes your package's render path depend on a third party answering.

Fix two: the fonts were the same bug, one directive over

Shipping the stylesheet in 1.2.4 left this in the head:

<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700|jetbrains-mono:400,500" rel="stylesheet" />
Enter fullscreen mode Exit fullscreen mode

I'd deliberately left the fonts remote and written a comment justifying it: webfonts are progressive enhancement, the stacks fall back to system-ui, so losing them costs typography and never layout.

That reasoning is correct and completely beside the point. A host running style-src 'self' 'unsafe-inline' refuses that <link> outright. It wasn't degrading gracefully in production — it was never arriving. Same class of failure as the CDN script, one directive over, hidden behind a comment that explained why it was fine.

So the latin subsets of Inter (400/500/600/700) and JetBrains Mono (400/500) now ship in resources/dist/fonts — six woff2 files, about 144 KB — declared by the package's own stylesheet with relative URLs that resolve under /vendor/artisan-runner/. font-src 'self' covers them without the host adding a single directive.

144 KB is a real cost. I'll take it over a UI that arrives unstyled in the one environment that matters.

The test is the actual deliverable

Here's the thing about a defect that only appears under a policy you don't run locally: fixing it teaches you nothing if the next contributor can reintroduce it and watch every check go green.

So the rule got encoded:

it('requests no asset from a third-party host', function () {
    // A host application running `default-src 'self'` refuses every one of
    // them, and it does so only in production — which is why this shipped
    // working on every developer machine.
    $views = array_merge(
        glob(__DIR__.'/../resources/views/**/*.blade.php'),
        glob(__DIR__.'/../resources/views/*.blade.php'),
    );

    foreach ($views as $view) {
        $markup = preg_replace('/\{\{--.*?--\}\}/s', '', file_get_contents($view)) ?? '';

        expect($markup)->not->toMatch('#(src|href)=[\'"]https?://#');
    }
});

it('self-hosts the webfonts it declares', function () {
    $dist = ArtisanRunnerServiceProvider::DIST_PATH;
    $css = file_get_contents($dist.'/artisan-runner.css');

    expect($css)->toContain('@font-face');

    foreach (glob($dist.'/fonts/*.woff2') as $font) {
        expect($css)->toContain('fonts/'.basename($font));
    }
});
Enter fullscreen mode Exit fullscreen mode

Two things I'd point at if this were a code review:

It strips Blade comments before matching. The layout is full of {{-- … --}} blocks explaining why the CDN URL is gone — and those blocks contain the URL. A naive regex over the raw file fails on its own documentation. Comment-stripping isn't a nicety here; without it the test punishes you for explaining yourself.

It asserts the shape, not the render. Nothing boots a browser. Nothing needs a CSP header. The assertion is "no view declares a remote asset", which is a property you can check statically and which holds regardless of what any host's policy happens to say. The whole reason the bug survived review is that the behaviour is environment-dependent — so test the thing that isn't.

The font test is the pair to it: the first test says nothing remote goes out, the second says the local replacement is actually wired. Delete either one and you can ship a page that references fonts that don't exist, or fonts that exist and nothing references.

The packaging bit

Build sources are export-ignored in .gitattributes:

/resources/css        export-ignore
/package.json         export-ignore
/package-lock.json    export-ignore
Enter fullscreen mode Exit fullscreen mode

The tarball needs the compiled resources/dist, not the pipeline that produced it.

And the flip side, which the CSS source now says in a comment at the top: npm run build has to be re-run after touching any Blade view in the package. The stylesheet is generated by scanning those views —

@import 'tailwindcss';

/* The package's own views are the only source of classes. */
@source '../views/**/*.blade.php';
Enter fullscreen mode Exit fullscreen mode

— so forget the rebuild and you ship a stylesheet missing the utility you just used. Which fails exactly like the CDN did, only quieter.

Takeaway

If you maintain a Laravel package with any UI at all, go read its layout right now and ask one question of every src= and href=: does this work under default-src 'self'?

If the answer is no, you haven't shipped a package. You've shipped a package plus a CSP amendment, and you've made it the host application's job to discover that — in production, from a console log, on a page that's already broken.

Self-contained is the contract. Everything the UI needs travels in the tarball.

Next up on my list: the same audit across the other packages. This one won't be the only offender.

Top comments (0)