Search docs

Jump between documentation pages.

Secure-by-default

Daloy is the first release in the “secure-by-default” series. It flips secure headers and cross-origin write protection on by default, adds a per-route content type opt-in, and keeps a single master escape hatch (secureDefaults: false) plus per-feature opt-outs for the rare cases where you genuinely need the old behavior.

What flipped

1. secureHeaders() is now auto-applied

Every new App() instance ships secureHeaders() with the same sensible defaults the middleware has always had: HSTS, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, a strict Referrer-Policy, and a baseline CSP. No code change required.

ts
import { App } from "@daloyjs/core";

const app = new App();
// secureHeaders() already attached — no app.use(secureHeaders()) needed.

If you call app.use(secureHeaders(...))with your own configuration, the auto-installed instance is automatically removed so your overrides win instead of being silently shadowed by the framework's defaults.

ts
import { App, secureHeaders } from "@daloyjs/core";

const app = new App();
app.use(
  secureHeaders({
    contentSecurityPolicy: "default-src 'self'; script-src 'self' 'nonce-{nonce}'",
    frameOptions: "SAMEORIGIN",
  }),
);
// The framework's default secureHeaders is dropped; your config is the only one active.

Want the headers configured at construction time instead? Pass a secureHeaders object to new App():

ts
const app = new App({
  secureHeaders: { frameOptions: "SAMEORIGIN" },
});

To opt out entirely (e.g. you serve content from a CDN that injects its own headers):

ts
const app = new App({ secureHeaders: false });

2. Cross-origin POST / PUT / PATCH / DELETE require cors()

State-changing requests carrying an Origin header from a different origin than the request URL are now rejected with 403 problem+json unless the matched route has a cors() policy that allows that origin. Read-only methods (GET, HEAD, OPTIONS), same-origin requests, and requests without an Origin header (or with Origin: null from a sandboxed iframe) pass through unchanged.

ts
import { App, cors } from "@daloyjs/core";

const app = new App();
app.use(cors({ origin: ["https://app.example.com"] }));
// Register this before the routes it should apply to.
// Cross-origin POST from https://app.example.com now passes through to your handler.

Per-route opt-in works too — register the cors() hook on the specific routes that need it via route({ hooks: cors({...}) }).

To disable the guard entirely (you handle cross-origin admission another way, e.g. via csrf() with the fetch-metadata strategy):

ts
const app = new App({ corsCrossOriginGuard: false });

3. Per-route accepts field

New route({ accepts: [...] }) field overrides the global allowedContentTypes allowlist for a single route. Useful for legacy form-encoded webhook receivers without loosening the default allowlist for the rest of your app.

ts
app.route({
  method: "POST",
  path: "/legacy/webhook",
  operationId: "legacyWebhook",
  accepts: ["application/x-www-form-urlencoded"],
  request: { body: z.object({ payload: z.string() }) },
  responses: { 200: { description: "ok" } },
  handler: async ({ body }) => ({ status: 200 as const, body: { ok: true } }),
});

The master escape hatch

If you're upgrading from 0.15.x and need to ship the upgrade without any behavior changes, pass secureDefaults: false to restore the pre-0.16 behavior wholesale:

ts
const app = new App({ secureDefaults: false });

This is intentionally one-shot: there is no per-feature granular master flag because the per-feature opt-outs already exist (secureHeaders: false, corsCrossOriginGuard: false). Use secureDefaults: false as a time-boxed migration hatch, not a permanent posture.

Detection markers (advanced)

The framework detects secureHeaders() and cors() registration via two exported symbols. If you wrap these middleware in your own helpers, stamp the marker on your returned hooks to get the same behavior:

ts
import {
  cors,
  secureHeaders,
  CORS_HOOK_MARKER,
  CORS_ORIGIN_ALLOW_MARKER,
  SECURE_HEADERS_MARKER,
} from "@daloyjs/core";

export function myCors() {
  const hooks = cors({ origin: ["https://app.example.com"] });
  // already stamped with CORS_HOOK_MARKER and CORS_ORIGIN_ALLOW_MARKER.
  return hooks;
}

export function myCustomHeaders() {
  const hooks = secureHeaders({ frameOptions: "SAMEORIGIN" });
  // already stamped; the auto-installed instance will be dropped when you use() this.
  return hooks;
}

Migration checklist

  • Audit any custom secureHeaders() call sites. Behavior is the same — the auto-installed instance is automatically replaced when you register your own.
  • Audit any cross-origin POST / PUT / PATCH / DELETE tests / integrations. Register cors() (recommended) or pass corsCrossOriginGuard: false (if you handle cross-origin admission via csrf({ strategy: 'fetch-metadata' }), for example).
  • For legacy form-encoded routes, add accepts: ["application/x-www-form-urlencoded"] on the route definition.
  • If you must ship the upgrade with zero behavior change while you triage, set secureDefaults: false as a temporary escape hatch.

What's not in this slice

The full secure-defaults plan in the roadmap lists many additional flips (CSP nonces default-on, per-content-type body caps, response-schema validation in development, conditional/openapi.json in production, frame-ancestors 'none' as immovable, trailing-slash canonicalization, etc.). Those will land in additive 0.16.x patches and follow-up minor releases. The four-item slice above is what shipped first because it is the highest-impact-per-line-of-breaking-change subset.