@blueforge-studio/api-guard

Framework-agnostic API security guards (bearer/cron secrets, rate limit, origin, ingest caps, SSRF, redirects, access tokens, secret scrubbing) on web-standard Request/Response, with Hono and Next adapters and an api-guard-lint route scanner.

Informations sur le paquet

Latest Version0.2.0
LicenseMIT
Keywords-
Repositorygit+https://github.com/blueforge-studio/blueforge-org.git
Author-
Homepagehttps://github.com/blueforge-studio/blueforge-org#readme
Bugs[object Object]

Versions

VersionPubliée
0.2.02026-11-03
0.1.02026-10-19

Chaîne d'approvisionnement par version

VersionSigSBOMAtt
0.2.0
0.1.0

Cliquez sur une version pour voir ses détails.

Chaîne d'approvisionnement

Provenance

Publié par
web:5
Quand
2026-11-03T21:49:13Z
sha256 (calculé par le registre)
9c0f3739fbd58367b03dd6aabf0beb2118ca2e963e9c9535f6202f4e1cd13cc2

Observé par le registre au moment de la publication.

Déclaré par la compilation

L'éditeur n'a rien déclaré sur sa compilation.

Pièces jointes

Signature
aucune
SBOM
aucune

Dépendances

Chargement du graphe de dépendances...

README

@blueforge-studio/api-guard

API-security guards for route handlers, extracted from the same mistakes found in five repos by the 2026-10-16/18 audits. Pure functions on web-standard Request / Response, so they work in Next route handlers, Hono, Workers and plain fetch servers. Zero runtime dependencies, TypeScript strict, ESM only.

import { requireBearerSecret, rateLimit, originGuard } from "@blueforge-studio/api-guard";
import { withGuards } from "@blueforge-studio/api-guard/next";   // Next app router
import { honoGuard } from "@blueforge-studio/api-guard/hono";    // Hono

A guard is (request) => Response | null | Promise<...>: return a Response to stop the request, null to let it through. composeGuards(a, b) and runGuards(request, [a, b]) chain them.

// Next: app/api/cron/rollup/route.ts
const limiter = rateLimit({ windowMs: 60_000, max: 10 });
export const POST = withGuards([requireBearerSecret({ env: "CRON_SECRET" }), limiter], async () => {
  await rollup();
  return Response.json({ ok: true });
});

// Hono
app.post("/cron/rollup", honoGuard(requireBearerSecret(), limiter), (c) => c.json({ ok: true }));

Env-reading guards take getEnv (default process.env) for Workers bindings and tests. Each section below names the threat and the PR it came from.

Modules

timingSafeEqualString(a, b)

Constant-time equality over UTF-16 code units for max(len) iterations. Never throws; unequal length, multi-byte input, lone surrogates and non-strings simply compare unequal. Threat: crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) throws RangeError on unequal byte length, so a multi-byte header crashed the webhook route. Source: property-platform #50.

requireBearerSecret({ env, secret?, getEnv?, allowQueryToken?, queryParam? })

For cron, webhook and service routes. Secret unset or blank: 503, fails closed. Missing or wrong Authorization: Bearer: 401 with WWW-Authenticate. There is no default value, ever. Query-string tokens are off unless allowQueryToken: true (they leak into logs). extractBearer(request) is exported. Threat: cron routes that defaulted to a public secret or skipped the check when the env var was unset; unauthenticated mutating cron GETs. Sources: booking-app-v2 #47, rental-platform #22, marketing-os rollup (if (cronSecret) {...}).

rateLimit({ windowMs, max, key?, store?, maxKeys?, now? })

Fixed-window limiter: 429 with Retry-After. Create one per route (per-route limits by construction). Default key is the client IP from X-Forwarded-For / X-Real-IP, which is only trustworthy behind a proxy that overwrites them: pass key otherwise (e.g. user id, API key). The default MemoryRateLimitStore is per-process and bounded (maxKeys, default 10 000: expired windows are swept, then oldest evicted). Implement RateLimitStore.hit(key, windowMs, now) to back it with Redis (INCR + PEXPIRE) or Postgres. limiter.check(key) works without a Request. Threat: no limit, or one global limit, on expensive and abusable routes. Sources: property-platform #50, booking-app-v2 #47, contracts-os #48.

originGuard({ allowedHosts, allowSameHost = true, allowMissing = false })

403 unless the Origin (else Referer) host is allowlisted (anchored, subdomains included) or equals the request's own host. Both headers missing is refused by default. A soft check: non-browser clients can forge these headers. It stops drive-by browser abuse of public endpoints; it is not authentication. Source: contracts-os #48, marketing-os track guard (browser callers recognised by registered domain).

ingestGuard({ maxBodyBytes, maxEvents, allowedContentTypes, dropKeys, caps, timestampKey, rateLimit, origin })

The open-ingest model from marketing-os docs/audits/2026-10-16-api-track-open-ingest.md. Returns { ok: true, events, droppedFields, skippedEvents } or { ok: false, response }:

CheckDefaultResult
content typeapplication/json, text/plain (sendBeacon)415
body size (declared and streamed, so a missing or lying Content-Length cannot buffer)512 KB413
events per request (array, {events}, or one object)100413
origin / per-client rate limit (optional)off403 / 429
strings, keys, depth, properties size2048 chars, 64 keys, depth 4, 16 KBtruncated or dropped
client-trusted fields (any depth, case-insensitive)revenue, revenue_cents, amount, price, user_id, email, created_at, ...dropped (droppedFields lists them)
timestamp7 days back, 5 minutes forwardout of window or invalid is replaced by server time

__proto__ / constructor / prototype keys are dropped. Revenue and identity must come from a trusted server path. Sources: marketing-os (audit + track guard), contracts-os #48.

priceAllowlist({ env = "STRIPE_ALLOWED_PRICE_IDS", fallbackEnv = "STRIPE_DEFAULT_PRICE_ID" })

isAllowed(id), resolve(requested?), list(). Rejects every id that is not listed, and everything when nothing is configured. The fallback env is used only when the list is empty; resolve() with no argument returns the default. Threat: checkout accepting any client-supplied priceId. Source: rental-platform #22.

safeRedirect(target, { allowedHosts, fallback = "/", allowRelative, protocols }) and trustedOrigin(request, { allowedHosts, fallback })

safeRedirect returns the target only when it is a relative path (/x, never //x or /\x) or an http(s) URL on an allowlisted host with no userinfo; control characters (tab/CR/LF are stripped by browsers, turning /\t/evil into //evil), backslashes and overlong values yield fallback. trustedOrigin picks the origin for success/cancel URLs: the Origin header only if allowlisted, else your configured base URL. Threat: redirect built from the request's Origin header; open redirects. Source: rental-platform #22.

hostAllowlist(hosts), isHostAllowed(host, hosts)

Anchored matching: host === h || host.endsWith("." + h), after lowercasing and stripping port, brackets and a trailing dot. A leading *. on an entry is accepted. Threat: an unanchored regex let evil.airbnb.com.attacker.example pass an airbnb.com allowlist. Source: rental-platform #22.

assertPublicHttpUrl(url, { allowedHosts, allowHttp, resolve }) and safeFetch(url, { maxRedirects = 3, ... })

SSRF helpers for server-side fetches of user-supplied URLs. assertPublicHttpUrl throws UnsafeUrlError (with a code) for non-http(s), userinfo, localhost / .local / .internal / metadata* names, single-label hosts, private, loopback, link-local (169.254.169.254), CGNAT, multicast and reserved IPv4/IPv6 literals (including IPv4-mapped IPv6 and the decimal/hex/octal IPv4 spellings, which the URL parser folds to dotted quad), and anything outside allowedHosts. safeFetch follows redirects manually and re-validates every hop, so an allowlisted host that 302s to the metadata service is refused. Pass resolve (a DNS lookup returning addresses) to also reject public names that point at private ranges; that check is best-effort against DNS rebinding because resolution and connection are separate steps. Prefer an allowedHosts list whenever the set of sources is known. Source: rental-platform #22 (iCal fetch).

scrubSecrets(textOrObject), scrubString(text)

Redacts JWTs, Bearer tokens, Stripe keys, scheme://user:pass@, token-like query values (token, access_token, api_key, code, sig, ...), emails, and the values of credential-looking keys (password, secret, token, authorization, cookie, session, ...) in objects. Errors become { name, message } (no stack). Cycle-safe, depth-limited, never mutates the input. Run it on anything going to a log line or an error response. Source: contracts-os #48.

notFound(), foundOrNotFound(value), isResponse, internalError()

One uniform 404 ({"error":"Not found"}, private, no-store) for missing, expired, revoked and wrong-tenant. Never distinguish them in status, body or headers. internalError() is a generic 500 that echoes nothing. Source: garage-os #49.

Access tokens: generateAccessToken(), hashAccessToken(token), verifyAccessToken(store, presented, { now, tenantId }), expiryFromTtl(ms)

Storage-agnostic. generateAccessToken returns 256 random bits as base64url (43 chars) plus its SHA-256 hex; store only the hash, show the token once, deliver it in a URL fragment (not the query string) and send it back as a Bearer header. verifyAccessToken takes any { findByHash(hash), touch?(id, now) }, re-compares the hash in constant time, and returns the record or null for every failure (malformed, unknown, expired, revoked, wrong tenant), so the route answers notFound() and the reason is unobservable. Record shape: { id, tokenHash, expiresAt, revokedAt?, tenantId? }. Web Crypto only (Node 20+, Workers, browsers). Source: garage-os #49.

requireRole(allowed) and ownerScoped(query, ctx)

requireRole(["staff", "owner"])(ctx) returns 401 without a role, 403 for a disallowed role, else null. Resolve the role from your own session first (it is deliberately not tied to any auth library). ownerScoped(query, { ownerId, tenantId? }) overwrites the owner/tenant fields from the server-side context (whatever the client sent is discarded), throws MissingScopeError without an owner (fail closed) and returns an OwnerScoped<Q> branded type. Make repository helpers take OwnerScoped<Filter> and an unscoped query stops compiling. It returns a plain object for your own query layer; there is no ORM here. Sources: property-platform #50 (tenant/owner scoping), booking-app-v2 #47 (staff-only email/SMS/refund), rental-platform #22 (client-supplied customerId).

api-guard-lint: find unguarded route handlers

A bin in this package (no extra dependency). It scans a repo for

  • Next app-router route.ts|js files exporting GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS (function, const, or export { handler as POST }), and
  • Hono / Express registrations: app.get|post|put|patch|delete|all('/path', ...) and router.* in files that import hono or express (receiver is a new Hono() / Router() variable or named app, router, api, *App, *Router, ...)

and reports handlers with no recognisable guard. A handler counts as guarded when a guard identifier appears in its own body or arguments (including wrappers: withGuards([...], handler), app.post('/x', requireUser(), handler)), when a top-level binding of a guard is used (const guard = requireBearerSecret(); ... guard(req)), or, for Hono, when an earlier app.use(...) whose arguments contain a guard covers the route's path prefix. Comments are ignored.

Default guard identifiers: requireUser, requireSession, requireAuth, requireRole, requireStaff, requireAdmin, requireBearerSecret, requireApiKey, getSession, getServerSession, getCurrentUser, getUser, getAuthContext, getAuth, currentUser, authMiddleware, authenticate, authorize, withAuth, withGuards, honoGuard, verifyWebhook, verifySignature, constructEvent, timingSafeEqualString, verifyAccessToken. Add your own with --guard name or "guards" in api-guard.config.json.

pnpm exec api-guard-lint [dir] [--json] [--strict] [--allow file] [--config file] [--guard name] [--ignore glob]
ExitMeaning
0no unguarded, non-allowlisted mutating route (reads are warnings)
1an unguarded mutating route exists, or an unguarded read under --strict
2usage or config error (bad flag, unreadable config, allow entry without a reason)

POST/PUT/PATCH/DELETE (and Hono all) are mutating. A GET on a cron/job-style path (cron, jobs, tasks, sync, seed, migrate, rollup, dispatch, reset) is also treated as mutating, because those are exactly the "unauthenticated mutating cron GET" routes from rental-platform #22; override with mutatingGetPatterns (regex sources) in the config.

Allowing intentionally public routes: api-guard.allow.json

{
  "routes": [
    { "file": "src/app/api/health/route.ts", "reason": "liveness probe, returns a constant" },
    { "file": "src/app/api/track/route.ts", "method": "POST", "reason": "open ingest, protected by ingestGuard in the handler wrapper" },
    { "file": "server/*.ts", "path": "/webhooks/*", "reason": "signature verified by the reverse proxy" }
  ]
}

file and path accept * (one segment) and ** globs, method is optional, and reason is required (at least 8 characters; otherwise exit 2). Entries that match nothing are reported as stale. api-guard.config.json: { "guards": [], "replaceDefaults": false, "ignore": ["legacy/**"], "mutatingGetPatterns": [] }.

Output

Human report (one line per finding, then a summary and OK/FAIL), or --json: { root, strict, filesScanned, findings: [{ file, line, framework, method, path, kind, status: "guarded"|"allowed"|"unguarded", guard, via, allowReason }], staleAllow, configErrors, summary, failed }. Programmatic use: import { lintRepo, scanSource } from "@blueforge-studio/api-guard/lint".

Wiring it into a repo's CI (one step)

Add @blueforge-studio/api-guard as a devDependency, commit an api-guard.allow.json for the intentionally public routes, and add one step after install (Gitea Actions / any CI):

      - name: API guard lint
        run: pnpm exec api-guard-lint .

Start with the allow file generated from the first run's report, review each reason, then keep the step required. Use --json > api-guard-report.json if you want to upload the report as an artifact.

Limits (it is a text scan, not a data-flow analysis)

  • A recognised guard identifier anywhere in the handler counts, even if it is called after the mutation or its result is ignored. It finds the missing guard, not a wrong one.
  • Auth done in Next middleware.ts, a reverse proxy or a wrapper the scanner cannot see needs an allow entry (with the reason).
  • app.use coverage is positional and prefix-based; routers mounted with app.route('/x', sub) are scanned per file, without the parent's use. Dynamic paths (non-literal first argument) are skipped.
  • Strings containing guard names (not comments) can produce a false "guarded".

What this package does not do

  • It does not authenticate users. Use your auth library for sessions; use requireRole / ownerScoped on its output.
  • Path traversal (../) in uploads and file reads (property-platform #50, booking-app-v2 #47) is solved by never using a client path: generate the object key server-side. If you must accept a name, validate it against ^[A-Za-z0-9._-]+$, reject .., and resolve it under a fixed root. A helper is intentionally left out of 0.1.0.
  • Production gating of seed/dev routes (booking-app-v2 #47) is a one-liner: if (process.env.NODE_ENV === "production") return notFound();.
  • The in-memory rate-limit store is per instance. Use a shared RateLimitStore when you run more than one.

Build and test

pnpm --filter @blueforge-studio/api-guard build   # tsc -> dist/
pnpm --filter @blueforge-studio/api-guard test    # vitest