Skip to content

Security Hardening ​

BananaJS ships several security primitives. This guide maps each one to the OWASP Top 10 category it addresses and shows the recommended production configuration.

Quick checklist ​

ConcernPrimitiveDefault safe?
HTTP security headershelmet✅ enabled by default
CORS origin restrictioncors + createCorsOptions⚠️ warns if no origin set
Request body size limitbodyLimit✅ 1mb default
URL-encoded prototype pollutionurlencoded extended✅ false by default
Rate limiting (global)rateLimit option⚠️ opt-in
Per-route throttling@Throttle⚠️ opt-in
AuthenticationAuthGuard + @Auth⚠️ opt-in
Role-based accessRolesGuard + @Roles⚠️ opt-in
Resource/action accessAbacGuard + @Can⚠️ opt-in
Input validation@Body / @Query (validation)⚠️ opt-in per route
HTML sanitisation@Sanitize⚠️ opt-in per route
Secret managementBananaConfig sensitive⚠️ opt-in per field
Log redactionPinoLogger default paths✅ enabled by default
Dependency CVEsnpm audit + Dependabot✅ CI enforced

A05 — Security Misconfiguration ​

HTTP security headers (Helmet) ​

Helmet is enabled by default. For production, configure Content Security Policy and HSTS preload:

typescript
import { BananaApp, type HelmetOptions } from '@banana-universe/bananajs'

const helmetConfig: HelmetOptions = {
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
  hsts: {
    maxAge: 31536000,       // 1 year
    includeSubDomains: true,
    preload: true,
  },
}

new BananaApp({
  controllers: [...],
  security: { helmet: helmetConfig },
})

To disable helmet entirely (testing only — never in production):

typescript
security: { helmet: false }

CORS — explicit origin allowlist ​

BananaJS logs a warning at startup when CORS is enabled without an explicit origin, because the default allows all origins. Use createCorsOptions in production:

typescript
import { BananaApp, createCorsOptions } from '@banana-universe/bananajs'

new BananaApp({
  controllers: [...],
  security: {
    cors: createCorsOptions([
      'https://app.example.com',
      'https://admin.example.com',
    ]),
  },
})

Request body size limit ​

The default is 1mb. Lower it for APIs that only handle small payloads:

typescript
new BananaApp({
  controllers: [...],
  bodyLimit: '100kb',
})

Requests that exceed bodyLimit are rejected with 413 Payload Too Large before they reach any controller.


A03 — Injection ​

Input validation ​

Validate every external input at the boundary using @Body, @Query, @Params, or @Headers. The decorator parses the request through the schema and replaces req.body (etc.) with the validated, type-safe value — downstream code never touches raw unvalidated input.

typescript
import { z } from 'zod'
import { Controller, Post, Body } from '@banana-universe/bananajs'

const CreateUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(12),
})

@Controller('users')
export class UserController {
  @Post('')
  create(@Body(CreateUserSchema) body: z.infer<typeof CreateUserSchema>) {
    // body is guaranteed to match the schema
  }
}

HTML sanitisation ​

Use @Sanitize on handlers that accept HTML content (rich text editors, CMS fields). Requires sanitize-html peer:

typescript
import { Sanitize } from '@banana-universe/bananajs'

@Post('articles')
@Sanitize({ allowedTags: ['b', 'i', 'em', 'strong', 'a'], allowedAttributes: { a: ['href'] } })
createArticle(@Body(ArticleSchema) body: ArticleDto) { ... }

SQL / NoSQL injection prevention ​

TypeORM — always use query builder parameters or the ORM's entity methods. Never interpolate user input into raw query strings:

typescript
// ✅ safe
await repo.findOne({ where: { email: userInput } })
await repo.createQueryBuilder('u').where('u.email = :email', { email: userInput }).getOne()

// ❌ unsafe
await dataSource.query(`SELECT * FROM users WHERE email = '${userInput}'`)

Mongoose — avoid passing raw query objects from req.body directly to Mongoose operators ($where, $regex). Validate shapes before querying:

typescript
// ✅ validated first
const parsed = QuerySchema.parse(req.query)
await UserModel.findOne({ email: parsed.email })

A07 — Authentication Failures ​

AuthGuard ​

Implement AuthGuard and pass it to BananaApp. See Authentication for the full API. Key points:

  • Sign JWTs with a strong secret stored in an environment variable, never in source code.
  • Use short expiry (15m – 1h) and implement refresh token rotation.
  • Attach user to req inside canActivate — the framework stores it in RequestContext.
typescript
new BananaApp({
  controllers: [...],
  auth: { guard: jwtAuthGuard },
})

ABAC with @Can ​

For resource/action access control that goes beyond roles, implement AbacGuard:

typescript
import type { AbacGuard } from '@banana-universe/bananajs'

export const abacGuard: AbacGuard = {
  can(action, resource, req) {
    const user = (req as any).user
    // e.g. 'delete' on 'document' requires ownership or admin role
    if (action === 'delete' && resource === 'document') {
      return user?.roles?.includes('admin') ?? false
    }
    return true
  },
}

new BananaApp({
  controllers: [...],
  auth: { guard: jwtAuthGuard },
  abac: { guard: abacGuard },
})

Decorate routes with @Can(action, resource) — runs after authentication:

typescript
@Delete('documents/:id')
@Auth()
@Can('delete', 'document')
deleteDocument(@Params(ParamsSchema) params: { id: string }) { ... }

A02 — Cryptographic Failures ​

Secret management with BananaConfig ​

Mark sensitive configuration fields with sensitive: true. The framework:

  • Validates that required secrets are present at startup (fail-fast).
  • Prevents accidentally leaking them via JSON.stringify (e.g. in an error response).
typescript
import { BananaConfig } from '@banana-universe/bananajs'

export const config = BananaConfig({
  jwtSecret: { env: 'JWT_SECRET', required: true, sensitive: true },
  dbPassword: { env: 'DB_PASSWORD', required: true, sensitive: true },
  appName:    { env: 'APP_NAME',   default: 'MyApp' },
})

// Safe: returns real value for code use
const secret = config.get().jwtSecret

// Safe: '[REDACTED]' in JSON output — never leaks in logs or error responses
JSON.stringify(config.get())
// => '{"jwtSecret":"[REDACTED]","dbPassword":"[REDACTED]","appName":"MyApp"}'

A09 — Security Logging and Monitoring Failures ​

PinoLogger default redaction ​

PinoLogger redacts the following field paths from log output by default:

  • password, token, authorization, cookie
  • *.secret, *.apiKey, *.api_key, *.accessToken, *.access_token

You can extend the list via the constructor:

typescript
import { PinoLogger } from '@banana-universe/bananajs'

const logger = new PinoLogger({
  redact: {
    paths: ['*.ssn', '*.creditCard'],
    censor: '[REDACTED]',
  },
})

Custom paths are merged with the defaults — they do not replace them.


A06 — Vulnerable and Outdated Components ​

The repository enforces:

  • npm audit --audit-level=high on every PR and push to main (.github/workflows/security-audit.yml).
  • Dependabot weekly security PRs for all npm packages and GitHub Actions (.github/dependabot.yml).
  • CodeQL static analysis on every PR (.github/workflows/codeql.yml).

Rate limiting ​

Global rate limiting ​

Apply a default limit to all routes:

typescript
new BananaApp({
  controllers: [...],
  rateLimit: { windowMs: 60_000, max: 100 },
})

Requires express-rate-limit peer: npm install express-rate-limit.

Per-route throttling with @Throttle ​

@Throttle supports per-user or per-IP keying and an optional distributed store for multi-instance deployments:

typescript
import { Throttle } from '@banana-universe/bananajs'

@Post('login')
@Throttle({ windowMs: 60_000, max: 5, keyBy: 'ip', message: 'Too many login attempts' })
login(@Body(LoginSchema) body: LoginDto) { ... }

For distributed environments, implement ThrottleStore and pass it as store:

typescript
import type { ThrottleStore } from '@banana-universe/bananajs'

const redisThrottleStore: ThrottleStore = {
  async consume(key) { /* increment in Redis, throw on limit */ },
  async reset(key)   { /* delete key in Redis */ },
}

@Post('login')
@Throttle({ windowMs: 60_000, max: 5, keyBy: 'ip', store: redisThrottleStore })
login(@Body(LoginSchema) body: LoginDto) { ... }