@boldsec/next
v0.12.0
Published
BoLD live authorization monitoring for Next.js App Router: wrap a route, watch it for cross-user access (BOLA/IDOR), function-level (BFLA), mass-assignment (BOPLA), and tenant-isolation risks. Metadata only, fail-safe.
Maintainers
Readme
@boldsec/next
Live BOLA/IDOR monitoring for Next.js (App Router). Wrap an API route; BoLD watches it and raises an alarm the moment one user reaches another user's object — in real time, from your app's actual traffic.
- Metadata only. BoLD receives who made the request (an opaque, non-reversible handle — never the token), the endpoint shape, the object id, the status, and the object's declared owner if the response exposed one. The response body is read once to extract that single owner field and then discarded. No body, no headers, no credentials ever leave your app.
- Fail-safe. Shipping the metadata is fire-and-forget. If BoLD is slow or down, your response is returned untouched and on time. BoLD can never break or slow the app it watches.
- Never alters your response. Your handler runs exactly as before; its response is returned byte-for-byte.
Install
npm install @boldsec/nextConfigure (once)
Add your keys (from the BoLD Live page, shown once when you connect the app):
# .env.local
BOLD_INGEST_URL=https://<your-bold>/api/live/ingest
BOLD_INGEST_KEY=blk_...
# optional — owner-field names to look for in responses (default covers common ones)
BOLD_OWNER_FIELDS=ownerId,owner_id,user_idUse
Wrap the route handlers you want watched:
// app/api/invoices/[id]/route.ts
import { withBold } from "@boldsec/next";
export const GET = withBold(
async (req, ctx) => {
const invoice = await getInvoice(ctx.params.id);
return Response.json(invoice); // { id, ownerId, ... }
},
// STRONGLY RECOMMENDED — tell BoLD who the caller is, in the SAME namespace as your owner field:
{ resolveCallerId: (req) => getUserIdFromSession(req) }, // e.g. "usr_101"
);Works for GET / PUT / PATCH / POST / DELETE handlers. Redeploy, and your app's real
traffic is watched automatically — catches appear in your BoLD Findings with a live timestamp.
Many routes? Wrap them all at once
Editing 100 routes by hand is a lot of work. The codemod wraps every App Router route handler for you, and shows you the change before writing anything:
npx @boldsec/next init # DRY RUN: previews every change, writes nothing
npx @boldsec/next init --apply # writes the changes — then review with: git diff- Dry run by default. You always see exactly what would change first.
--applywrites; you review the git diff and commit. - Runs entirely on your machine. It never sends your code anywhere.
- Idempotent. Re-run any time — already-wrapped handlers are left untouched.
- Loud, never silent. Any handler it can't safely auto-wrap (e.g. a re-export) is listed for you to wrap by hand, so a route is never quietly left unwatched.
- It inserts a
TODO(BoLD)where yourresolveCallerIdgoes — fill in the one line for your auth stack (searchTODO(BoLD)), since only your app knows who the caller is.
Point at a non-default app directory with --dir src/app.
App shapes it reads
- REST object routes — string ids (
/api/invoices/inv_8101), numeric ids (/api/orders/4021), UUIDs. - Nested / oddly-named owner fields — the owner is found wherever it nests (bounded depth) as
long as the field name is in your owner-field list (e.g. add
holderIdfordata.account.holderId). - GraphQL — wrap your
app/api/graphql/route.tsthe same way. BoLD parses the operation and itsid(fromvariablesor an inline literal) and reads the owner fromdata.<field>. Single-object queries and mutations are watched; batched/list queries, or an id BoLD can't resolve, are skipped (it never guesses — a shape it can't read is held for review, never reported clean).
Mass assignment (BOPLA)
Mass assignment is when a normal user sets a field on a write that they must not: PATCH /users/me
with { "role": "admin" }, or { "owner_id": "...", "balance": 999999 }. On a write, BoLD reads the
request body locally, in your app and reports only the field NAMES that match a built-in
wordlist of high-signal privilege / ownership / trust / financial fields (role, is_admin,
owner_id, verified, balance, banned, ...).
Names only, never a value. BoLD sees that
rolewas in the write body, never what it was set to. And a body key that is not a known-sensitive name is dropped, so your app's own field names (your schema) never leave the app.Zero-config leads. With no setup, a wordlist hit on a write raises a loud needs-review lead in your Findings ("a write set
roleon this route -- declare it or dismiss it"). It is never auto-reported as CONFIRMED from live traffic.Declare your app's protected fields to raise leads on names outside the wordlist, and to let the one-click active check confirm a real mass assignment on a seeded test object:
export const PATCH = withBold(handler, { resolveCallerId: (req) => getUserIdFromSession(req), sensitiveFields: ["clearance", "commission_rate"], // your app-specific write-protected fields });Or set
BOLD_SENSITIVE_FIELDS(comma-separated) once for every route.
To keep the noise budget low, genuinely ambiguous, commonly user-settable names (status, active,
plan, tier, visibility, ...) are not in the default wordlist -- add them with
sensitiveFields if your app treats them as protected. Mass assignment has no passive CONFIRMED:
a live hit is always a needs-review lead, and the active seeded check is the only confirmer. (Field
extraction is REST-JSON, top-level keys; GraphQL mutations and non-JSON bodies do not raise a passive
lead -- absence of a lead is never an all-clear.)
Why resolveCallerId matters
Your app already authenticated the caller, so it knows their id (e.g. usr_101). Passing it lets
BoLD compare the caller against the object's owner directly: if they match, it's the owner's
own access and BoLD stays silent — no false alarm. Without it, BoLD falls back to a non-reversible
hash of the auth header, which can't be matched against your owner ids, so a user reading their
own object may be flagged. Always provide resolveCallerId in production.
Org / team apps: resolveCallerScopes
If your app is scoped by an org, team, tenant, or workspace (a nested route like
/orgs/{id}/items/{id}, or an owner field like tenantId), a teammate reading a colleague's item is
legitimate — the object is owned by the scope, not one person. BoLD recognises this: it never
raises a CONFIRMED problem on a scoped read it can't place the caller in (it holds it for review
instead). To judge these precisely — a real member stays silent, a genuine outsider is confirmed —
pass the scopes the caller belongs to:
export const GET = withBold(handler, {
resolveCallerId: (req) => getUserId(req), // e.g. "usr_101"
resolveCallerScopes: (req) => getUserOrgIds(req), // e.g. ["org_42", "org_7"]
});With it, a caller who is in the object's scope is treated as an authorized member (never flagged), and one who is not is judged as a cross-scope access. Without it, scoped reads are safely held for review — never a false alarm, just not auto-resolved.
If your scoping shape is unusual and BoLD can't auto-detect it from the URL or owner field, name it
explicitly with resolveObjectScope: (req) => "team_9". Combined with resolveCallerScopes, that
makes the member-vs-outsider guarantee airtight for any app shape.
Mark privileged routes (BFLA)
Set privileged: true on any admin or elevated-access route. BoLD then knows to verify that a
non-privileged user cannot reach it (function-level authorization / BFLA). The SDK includes
route_declared_privileged: true in the metadata event for that route; absent or false leaves the
event byte-for-byte unchanged.
export const DELETE = withBold(handler, { privileged: true });How it confirms a violation
BoLD confirms a BOLA when a request returns an object whose owner field identifies a different user than the caller. If a response doesn't expose an owner field, BoLD reports needs review rather than guessing — it never raises a false alarm.
