Embed loader with domain allowlist
Serve a widget script only to pages on approved domains, with wildcard subdomain matching and a frame-ancestors header to match.
LaravelMiddlewareembedcorsallowlistcsp
Code
<?php
namespace App\Embed;
use Illuminate\Http\Request;
final class OriginAllowlist
{
/** The page that embeds us: Origin header, else the Referer's origin. */
public static function requestOrigin(Request $request): ?string
{
$raw = $request->headers->get('Origin');
if (! $raw || $raw === 'null') {
$raw = $request->headers->get('Referer');
}
return $raw ? self::normalise($raw) : null;
}
/** scheme://host[:port] lower-cased, default ports removed, anything else rejected. */
public static function normalise(string $url): ?string
{
$p = parse_url(strtolower(trim($url)));
if (! isset($p['scheme'], $p['host']) || ! in_array($p['scheme'], ['http', 'https'], true)) {
return null;
}
$port = $p['port'] ?? null;
if (($p['scheme'] === 'https' && $port === 443) || ($p['scheme'] === 'http' && $port === 80)) {
$port = null;
}
return $p['scheme'].'://'.$p['host'].($port ? ":{$port}" : '');
}
/** Entries are exact origins; a leading "*." matches subdomains but not the bare domain. */
public static function allows(?string $origin, array $allowed): bool
{
if ($origin === null) {
return false; // an empty list therefore allows nothing
}
foreach ($allowed as $entry) {
$entry = strtolower(trim($entry));
if ($entry === $origin) {
return true;
}
if (str_contains($entry, '://*.')) {
[$scheme, $suffix] = explode('://*.', $entry, 2);
$host = parse_url($origin, PHP_URL_HOST);
if (str_starts_with($origin, $scheme.'://') && str_ends_with($host, '.'.$suffix)) {
return true;
}
}
}
return false;
}
}
<?php
namespace App\Http\Controllers;
use App\Embed\OriginAllowlist;
use App\Models\Site;
use Illuminate\Http\Request;
class EmbedController extends Controller
{
// Route::get('/embed/{siteKey}.js', EmbedController::class);
public function __invoke(Request $request, string $siteKey)
{
$site = Site::where('key', $siteKey)->firstOrFail();
$origin = OriginAllowlist::requestOrigin($request);
if (! OriginAllowlist::allows($origin, $site->allowed_origins ?? [])) {
return response('/* origin not allowed */', 403, ['Content-Type' => 'application/javascript']);
}
$js = 'window.AcmeWidget='.json_encode(['site' => $site->key, 'api' => url('/api')]).';'
.file_get_contents(public_path('widget/loader.js'));
return response($js, 200, [
'Content-Type' => 'application/javascript; charset=utf-8',
'Access-Control-Allow-Origin' => $origin,
'Vary' => 'Origin',
'Cache-Control' => 'private, max-age=300',
// The widget iframe may only be framed by the same approved sites.
'Content-Security-Policy' => 'frame-ancestors '.implode(' ', $site->allowed_origins),
]);
}
}
How it works
What it solves
An embeddable widget lives on other people's websites, but you do not want just anyone copying a customer's embed key and running their widget elsewhere. A domain allowlist ties each key to the sites the customer approved, without needing a login on the embedding page.
How it works
requestOrigin() takes the browser's Origin header, falling back to the origin of the Referer, because a plain script tag on the same site sends only a referer. normalise() reduces both forms to scheme://host[:port] in lower case and drops default ports, so https://Shop.Example.com:443/page and https://shop.example.com compare equal.
allows() supports two kinds of entries: an exact origin, or a wildcard such as https://*.example.com. The wildcard matches any subdomain but deliberately not the bare domain, and it keeps the scheme, so an http page cannot use an https entry. An empty list or a missing origin allows nothing.
The controller replies 403 with a harmless comment when the check fails. When it passes, it echoes the origin in Access-Control-Allow-Origin, adds Vary: Origin so caches stay correct, and sends a frame-ancestors policy for the widget frame.
Adapting it
- Store allowed origins as a JSON array on the site and validate each entry when saved.
- Allow
http://localhost:3000only when the site is flagged as a test site.
Pitfalls
- Referrer policies can strip the header; tell customers to keep
strict-origin-when-cross-originor looser. - This is access control for convenience, not strong secrecy, since origins can be spoofed outside browsers.
How to use it
- Add
key(string, unique) andallowed_origins(json) to asitestable and cast the latter toarray. - Put
OriginAllowlist.phpinapp/Embed/and the controller inapp/Http/Controllers/. - Register
Route::get('/embed/{siteKey}.js', EmbedController::class);. - Put your widget bootstrap script at
public/widget/loader.js. - Customers paste
<script async src="https://your-app.example.com/embed/KEY.js"></script>.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.