JORVEL
Security

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.

ts
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

The baseline 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.

ts
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.

ts
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.

ts
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

The default store evicts least-recently-used keys above maxKeys. Pass a custom store (Redis, KV) for multi-instance deployments.

The default key trusts X-Forwarded-For

With no 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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
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

ts
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

Only 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

ts
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

ts
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

Without 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.

ThreatMitigationDefense layer
Untrusted remote URL (config injection)RemoteAllowlist with wildcard rulesRuntime — fetch time
CDN tampering of remoteEntry.jsSRI hashes (sriHashFromUrl, federation.sri)Browser — script execution
XSS via hydration payloadsafeJsonForScript + nonceSerialization + CSP
Inline-script injection in remote markupstrict-dynamic CSP with base64url nonceBrowser — script-src
Credential/token leakage in logsAuditLogger with redactKeysApplication
Brute-force / scrapingcreateRateLimitGuard token-bucketEdge / 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.

edge/handler.ts
ts
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),
});