Security
@jorvel/security packages the primitives federated apps actually need: a CSP builder with strict-dynamic, SRI hashing for remoteEntry.js, an origin allowlist with wildcard support, base64url-validated nonces, and helpers for safe hydration. Every primitive is edge-runtime safe (Web Crypto, no Buffer, no node:crypto).
Content Security Policy
buildCsp emits a strict policy with sensible defaults and validates the nonce token. strict-dynamic is on by default whenever a nonce is provided.
import { buildCsp } from '@jorvel/security';
const policy = buildCsp({
nonce: cryptoRandomNonce(), // base64url
strictDynamic: true, // default when nonce is set
strictStyles: true, // disables 'unsafe-inline' on style-src
reportTo: 'https://acme.report-uri.com/r/d/csp/enforce',
extra: {
'connect-src': ["'self'", 'https://api.acme.dev'],
},
});Don't mix nonce + 'unsafe-inline'
'unsafe-inline' defeats nonce enforcement. buildCsp drops it from style-src when strictStyles is on, and from script-src whenever a nonce is set.img-src is locked down by default
img-src is 'self' data: — not a blanket https:. Add any image CDNs explicitly via extra: { 'img-src': ["'self'", 'data:', 'https://images.acme.com'] }.Security headers kit
securityHeaders()returns a secure-by-default header map you spread into any response — the edge adapter's headers option, the node adapter, or a Worker Response. It covers HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, and the Cross-Origin-* trio. Set CSP separately (via buildCsp) so it can carry a per-request nonce.
import { securityHeaders, buildCsp } from '@jorvel/security';
import { createEdgeAdapter } from '@jorvel/ssr';
export default createEdgeAdapter({
App, template, routes,
headers: securityHeaders(), // HSTS, nosniff, Referrer-Policy, COOP/CORP, …
csp: () => buildCsp({}, { nonce: generateNonce() }),
});Cross-origin MFE defaults
Cross-Origin-Resource-Policy defaults to cross-origin (a host fetches remoteEntry.js cross-origin; same-origin would block it), and Cross-Origin-Embedder-Policy is OFF by default (require-corp blocks cross-origin remotes unless every one sends CORP/CORS). Opt into stricter values deliberately.CSP middleware
Drop a per-request nonce into your Express/Connect/Fastify server with cspMiddleware. The middleware sets the header and exposes res.locals.cspNonce for downstream renderers.
import { cspMiddleware } from '@jorvel/security';
app.use(cspMiddleware({
remotes: ['https://cdn.example.com'],
reportOnly: false,
}));
app.get('/', (_req, res) => {
res.send(`<script nonce="${res.locals!.cspNonce}">…</script>`);
});Use cspFastifyHook as a Fastify preHandler hook, or cspHeaderFactory for framework-neutral header computation.
Rate limiting
RateLimiter implements a token-bucket per key. createRateLimitGuard returns a request guard that emits X-RateLimit-* headers on success and a 429 with Retry-After when the bucket is empty.
import { createRateLimitGuard } from '@jorvel/security';
const guard = createRateLimitGuard({
capacity: 60, // burst
refillPerSec: 30, // sustained
keyFor: (req) =>
req.headers?.['x-api-key'] ?? req.headers?.['x-forwarded-for'] ?? 'anon',
});
export default async function fetch(req) {
const r = guard({ url: req.url, headers: Object.fromEntries(req.headers) });
if (!r.allowed) {
return new Response(r.response!.body, {
status: r.response!.status,
headers: r.response!.headers,
});
}
// …route the request, attaching r.headers to the final response.
}In-memory only by default
maxKeys. Pass a custom store (Redis, KV) for multi-instance deployments.The default key trusts X-Forwarded-For
keyFor, the guard keys on the first x-forwarded-for hop. That header is client-spoofable unless you sit behind a trusted proxy — for untrusted ingress, pass keyForusing your platform's verified client IP (e.g. request.cf?.ip, x-real-ip) so an attacker can't rotate the key per request.Audit log
Track auth/admin actions with AuditLogger. Sensitive fields in metadata (passwords, tokens, cookies) are scrubbed before sinks see them. Redacted keys are matched case-insensitively, so apiKey, ApiKey, and APIKEY are all replaced.
import { AuditLogger, bufferSink } from '@jorvel/security';
const buf = bufferSink();
const audit = new AuditLogger({ sinks: [buf.sink], redactKeys: ['ssn'] });
await audit.success({
actor: user.id,
action: 'user.login',
resource: { type: 'user', id: user.id },
ip: req.headers['x-forwarded-for'],
requestId: req.id,
metadata: { token: 'tk-…', ssn: '…' }, // both replaced with '[REDACTED]'
});
await audit.denied({
actor: user.id,
action: 'org.delete',
resource: { type: 'org', id: org.id },
reason: 'insufficient role',
});Subresource Integrity
sriHashFromUrl fetches a URL and returns a SHA-256/384/512 integrity attribute. It rejects HTTP URLs by default — pass { allowHttp: true } only in tests.
import { sriHashFromUrl } from '@jorvel/security';
const integrity = await sriHashFromUrl(
'https://cdn.acme.com/dashboard/remoteEntry.js',
{ algo: 'sha384' },
);
// "sha384-AbCdEf..."SRI for a federation manifest
Use computeSriForManifest at build time to bulk-hash every remoteEntry.js in your manifest, then injectSriIntoHtml to patch the shell HTML with the matching integrity + crossorigin attributes. Both helpers run under Web Crypto so they work in Workers, Vercel Edge, and Node 19+ without node:crypto.
import { computeSriForManifest, injectSriIntoHtml } from '@jorvel/security';
const { entries, failures } = await computeSriForManifest(
manifest.map((m) => ({ name: m.name, entryUrl: m.entryUrl })),
{ algo: 'sha384', concurrency: 6 },
);
// Then patch the shell:
const html = injectSriIntoHtml(template, entries, { match: 'basename' });Enforcing SRI at load time
Once a remote carries an integrity hash, the browser refuses to run mismatched bytes. To make that mandatory, pass requireIntegrity to loadRemoteEntry/loadRemoteModule — a remote with no hash is rejected before any <script>is injected, so a tampered or typo'd entry URL can never run unverified code. The loader also rejects an integrity hash combined with crossOrigin: 'none', since the browser skips SRI checks on no-CORS scripts and would silently run the bytes anyway.
import { loadRemoteModule } from '@jorvel/runtime';
const mod = await loadRemoteModule(
{ name: 'dashboard', entryUrl: '...', integrity: 'sha384-...' }, // stamp from the SRI manifest
'./App',
{ requireIntegrity: true }, // fail closed if integrity is missing
);Sandboxed remotes
For untrusted code, mount the remote inside an iframe with the sandbox attribute and talk to it over a tiny postMessage RPC bridge that pins event.origin and event.source. buildSandboxIframeAttrs refuses dangerous tokens (allow-same-origin, allow-top-navigation) that would defeat the sandbox.
import { buildSandboxIframeAttrs, createSandboxBridge } from '@jorvel/security';
const attrs = buildSandboxIframeAttrs({
src: 'https://untrusted.example.com/remote.html',
permissions: ['allow-scripts'],
});
// → { src, sandbox: 'allow-scripts', referrerpolicy: 'no-referrer' }
const bridge = createSandboxBridge({
target: iframe.contentWindow!,
host: window,
expectedOrigin: 'https://untrusted.example.com',
});
const result = await bridge.request('search', { q: 'react' }, 2_000);OAuth 2.0 / OIDC + PKCE
Pure-protocol helpers — pair with any IDP (Auth0, Cognito, Keycloak, Okta). generatePkceChallenge produces a 43-byte verifier + S256 challenge. buildAuthorizeUrl emits the authorize redirect. parseAuthorizationResponse validates the state CSRF guard. exchangeCodeForTokens + refreshTokens POST the form-encoded grant; TokenStore auto-refreshes with concurrent-call coalescing.
import {
generatePkceChallenge, buildAuthorizeUrl,
parseAuthorizationResponse, exchangeCodeForTokens,
refreshTokens, TokenStore, tokenSetFromResponse,
} from '@jorvel/security';
// 1. login start
const pkce = await generatePkceChallenge();
const state = crypto.randomUUID();
sessionStorage.setItem('pkce', JSON.stringify({ ...pkce, state }));
location.href = buildAuthorizeUrl({
authorizationEndpoint: 'https://idp.example.com/authorize',
clientId: 'my-spa',
redirectUri: `${location.origin}/cb`,
scope: ['openid', 'profile', 'email'],
state,
codeChallenge: pkce.challenge,
});
// 2. callback
const { code } = parseAuthorizationResponse(location.href, state);
const tokens = await exchangeCodeForTokens({
tokenEndpoint: 'https://idp.example.com/token',
clientId: 'my-spa',
code,
redirectUri: `${location.origin}/cb`,
codeVerifier: pkce.verifier,
});
// 3. auto-refreshing store
const store = new TokenStore(tokenSetFromResponse(tokens), {
refresher: async (current) => tokenSetFromResponse(
await refreshTokens({
tokenEndpoint: 'https://idp.example.com/token',
clientId: 'my-spa',
refreshToken: current.refreshToken!,
}),
),
});
fetch('/api/me', { headers: { authorization: `Bearer ${await store.getAccessToken()}` } });Origin allowlist
import { RemoteAllowlist } from '@jorvel/security';
const list = new RemoteAllowlist([
'https://*.acme.com',
'https://**.cdn.cloudflare.net',
]);
list.allows('https://cdn.acme.com/x.js'); // true
list.allows('https://evil.cdn.cloudflare.net/x'); // true (multi-label match)
list.allows('https://acme.com/x.js'); // false (single-label needs subdomain)Schemes are restricted by default
http(s): URLs are allowed. Pass new RemoteAllowlist(rules, { schemes: ['file:'] }) to opt in to additional schemes — useful for tests, never for production.Nonce generation + validation
function cryptoRandomNonce(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '');
}Nonces are validated against /^[A-Za-z0-9_-]+$/ before being baked into the CSP — corrupted or forgotten nonces throw rather than silently producing an invalid policy. generateNonce() throws when Web Crypto is unavailable; it never falls back to an insecure Math.random() source.
Safe state hydration
import { safeJsonForScript, escapeHtml, pruneProtoKeys } from '@jorvel/security';
const head = `
<script id="__jorvel_state" type="application/json" nonce="${nonce}">
${safeJsonForScript({ user, flags })}
</script>
`;
// Render error messages back to the user (XSS-safe)
const message = escapeHtml(error.message);
// Sanitize untrusted maps before merging into runtime config
const safe = pruneProtoKeys(JSON.parse(rawConfig));Why this matters
safeJsonForScript, a string containing </script>in your serialized state breaks out of the script element and turns into reflected XSS. The helper escapes the closing-tag sequence and wraps circular-reference errors so they don't take down the request.Threat model
Federation surfaces three threat classes. Each JORVEL primitive maps to one of them.
| Threat | Mitigation | Defense layer |
|---|---|---|
| Untrusted remote URL (config injection) | RemoteAllowlist with wildcard rules | Runtime — fetch time |
CDN tampering of remoteEntry.js | SRI hashes (sriHashFromUrl, federation.sri) | Browser — script execution |
| XSS via hydration payload | safeJsonForScript + nonce | Serialization + CSP |
| Inline-script injection in remote markup | strict-dynamic CSP with base64url nonce | Browser — script-src |
| Credential/token leakage in logs | AuditLogger with redactKeys | Application |
| Brute-force / scraping | createRateLimitGuard token-bucket | Edge / origin |
Defense in depth recipe
Strict-CSP and SRI and allowlist together. Any one of them prevents a compromise; the combination forces an attacker to break the browser, the CDN, and your build pipeline simultaneously.
import { buildCsp, generateNonce, RemoteAllowlist } from '@jorvel/security';
import { createEdgeAdapter, LruHtmlCache } from '@jorvel/ssr/edge';
const allow = new RemoteAllowlist(['https://*.cdn.acme.com']);
export default createEdgeAdapter({
App,
template,
routes,
csp: (_req) => {
const nonce = generateNonce();
return {
header: buildCsp({
nonce,
strictDynamic: true,
strictStyles: true,
extra: { 'connect-src': ["'self'", 'https://api.acme.com'] },
}),
nonce,
};
},
beforeRemoteLoad: (descriptor) => allow.assertAllowed(descriptor.entryUrl),
});