| Version | Supported |
|---|---|
| 0.x.x | ✅ |
We take security seriously at OpenCTEM. If you discover a security vulnerability, please follow responsible disclosure practices.
- Open a public GitHub issue for security vulnerabilities
- Disclose the vulnerability publicly before it has been addressed
- Exploit the vulnerability beyond what is necessary to demonstrate it
-
Email us directly at security@openctem.io with:
- Description of the vulnerability
- Steps to reproduce
- Potential impact assessment
- Suggested fix (if any)
-
Use our bug bounty program (if available) through:
- HackerOne (coming soon)
| Severity | Acknowledgment | Resolution Target |
|---|---|---|
| Critical | 24 hours | 24-72 hours |
| High | 48 hours | 1-2 weeks |
| Medium | 5 business days | 2-4 weeks |
| Low | 5 business days | Next release |
Browser Next.js Server Backend API
| | |
|-- (1) Login (email/password) ---->| |
| |-- POST /api/v1/auth/login ---->|
| |<-- { refresh_token, tenants } -|
| | |
| |-- POST /api/v1/auth/token ---->|
| |<-- { access_token, exp } ------|
| | |
|<-- Set-Cookie: auth_token (httpOnly, secure, sameSite=lax) --------|
|<-- Set-Cookie: refresh_token (httpOnly, secure, sameSite=lax) -----|
|<-- access_token (in JSON response body, stored in Zustand) --------|
| | |
|-- API request /api/v1/findings -->| |
| (cookie sent automatically) |-- reads httpOnly cookie ------>|
| |-- Authorization: Bearer JWT -->|
| |<-- JSON response --------------|
|<-- JSON response (no token) ------| |
| Token | Storage | Lifetime | Access |
|---|---|---|---|
| Access Token (JWT) | In-memory via Zustand store | ~15 min (from backend expires_in) |
JavaScript only |
| Refresh Token | httpOnly secure cookie | 7 days (COOKIE_MAX_AGE) |
Server-side only |
Key implementation details (src/stores/auth-store.ts):
- Access token stored exclusively in Zustand state. Never persisted to cookies, localStorage, or sessionStorage.
- JWT decoded client-side via
atob()to extract user claims (sub,email,permissions,tenant_id,tenant_role). No signature verification on client -- verification is done server-side at the backend API. - Zustand devtools disabled in production (
enabled: process.env.NODE_ENV === 'development').
- Auto-refresh triggers
TOKEN_REFRESH_BEFORE_EXPIRYseconds before expiration (default: 300s = 5 minutes). - Calls
POST /api/auth/refreshwithcredentials: 'include'(sends httpOnly cookie). - Mutex-protected:
isRefreshingTokenflag prevents concurrent refresh attempts. - Permanent failure flag:
authPermanentlyFailedprevents infinite refresh loops. On failure, clears all auth state and redirects to login. - Token size monitored: logs warning when JWT approaches 4KB cookie limit.
All client-side API requests go through the Next.js API proxy route (src/app/api/v1/[...path]/route.ts), not directly to the backend:
- Browser sends request to
/api/v1/*(same-origin, cookie sent automatically). - Proxy reads
auth_tokenfrom httpOnly cookie. - Proxy forwards request to backend with
Authorization: Bearer <token>header. - If 401: proxy auto-refreshes token and retries once.
- Response returned to browser without exposing the token.
Benefits: Eliminates CORS issues, tokens never appear in client JavaScript for API calls, backend URL hidden from browser.
All auth cookies are set with:
httpOnly: true
secure: true (when NODE_ENV === 'production')
sameSite: 'lax'
path: '/'
Complete cookie inventory:
| Cookie | httpOnly | Sensitive | Purpose |
|---|---|---|---|
auth_token |
Yes | Yes | JWT access token |
refresh_token |
Yes | Yes | Refresh token |
oauth_state |
Yes | Yes | OAuth CSRF state parameter |
oauth_redirect |
Yes | No | Post-OAuth redirect URL |
csrf_token |
Yes | Yes | CSRF protection token |
app_tenant |
No | No | Current tenant info (display only) |
app_user_info |
No | No | User info for onboarding (5-min TTL) |
app_pending_tenants |
No | No | Multi-tenant selection (5-min TTL) |
locale |
No | No | Language preference |
theme |
No | No | Theme preference |
- User submits credentials to Server Action (
loginAction). - Server Action calls backend
POST /api/v1/auth/login. - Backend returns
refresh_token+ list of tenant memberships. - If single tenant: exchanges refresh token for tenant-scoped access token via
POST /api/v1/auth/token. - If multiple tenants: stores tenant list in non-httpOnly cookie for selection UI.
- If zero tenants: redirects to team creation onboarding.
- Access token returned in response body (stored in Zustand), refresh token set as httpOnly cookie.
logoutAction() clears ALL auth cookies (auth_token, refresh_token, app_tenant, app_user_info, app_pending_tenants) and redirects to /login.
- Supported providers: Google, GitHub, Microsoft.
- CSRF protection: Random
stateparameter stored in httpOnlyoauth_statecookie, validated on callback. - Token flow follows same httpOnly cookie pattern as local auth.
forgotPasswordAction() always returns { success: true } regardless of whether the email exists in the system.
- 150+ permissions defined in
src/lib/permissions/constants.ts. - Hierarchical format:
{module}:{subfeature}:{action}(e.g.,assets:groups:write). - Permissions are the source of truth from the API -- no client-side bypass for Owner/Admin roles.
The PermissionProvider (src/context/permission-provider.tsx) keeps permissions in sync:
| Trigger | Condition | Behavior |
|---|---|---|
| Initial load | Always | Fetch from /api/v1/me/permissions/sync |
X-Permission-Stale header |
API response includes header | Immediate refetch |
| 403 Forbidden | API returns 403 | Immediate refetch |
| Tab focus | Tab hidden > 30 seconds | Refetch |
| Polling | Every 2 minutes | Refetch if stale |
- Debounce: Minimum 5 seconds between API calls.
- ETag support: Conditional
If-None-Matchrequests to reduce bandwidth. - localStorage cache: Permissions cached for instant UI render on page reload. Cleared on logout via
clearAllStoredPermissions().
// Hide element if user lacks permission
<Can permission="assets:write">
<EditButton />
</Can>
// Show disabled element with tooltip
<Can permission="assets:delete" behavior="disable">
<DeleteButton />
</Can>- JWT contains
tenant_idclaim scoping all API requests. - Backend enforces
WHERE tenant_id = ?on all data queries. - Permission cache keys include tenant ID to prevent cross-tenant data leaks.
- Team switching exchanges tokens via
POST /api/auth/switch-team.
The Next.js 16 proxy (proxy.ts) uses handleAuth() from src/lib/middleware/auth.ts:
- All routes require authentication by default.
- Public routes explicitly whitelisted:
/login,/register,/forgot-password,/reset-password,/verify-email,/auth/callback,/auth/error. - API routes (
/api/*) handled separately. - Authenticated users redirected away from auth pages.
validateRedirectUrl() ensures redirect targets:
- Start with
/(relative path) but not//(protocol-relative, which could redirect to external domains). - Or match the
NEXT_PUBLIC_APP_URLorigin exactly. - Falls back to
/dashboardif validation fails.
The proxy checks for cookie existence only (not validity). JWT signature verification happens at the backend API layer. This keeps the proxy lightweight and avoids duplicating verification logic.
Configured in next.config.ts, applied to all routes via /:path*:
| Header | Value | Purpose |
|---|---|---|
X-Frame-Options |
DENY |
Prevents clickjacking |
X-Content-Type-Options |
nosniff |
Prevents MIME type sniffing |
Referrer-Policy |
strict-origin-when-cross-origin |
Limits referrer leakage |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Restricts browser APIs |
| Directive | Value | Notes |
|---|---|---|
default-src |
'self' |
Baseline restriction |
script-src |
'self' 'unsafe-eval' 'unsafe-inline' |
Required by Next.js compiler |
style-src |
'self' 'unsafe-inline' https://fonts.googleapis.com |
Tailwind CSS + Google Fonts |
style-src-elem |
'self' 'unsafe-inline' https://fonts.googleapis.com |
Google Fonts stylesheets |
img-src |
'self' data: https: |
Allows HTTPS images for avatars/uploads |
font-src |
'self' data: https://fonts.gstatic.com |
Google Fonts files |
connect-src |
'self' https://*.openctem.io wss://*.openctem.io |
API + WebSocket connections |
frame-ancestors |
'none' |
Double protection with X-Frame-Options |
base-uri |
'self' |
Prevents base tag injection |
form-action |
'self' |
Restricts form submission targets |
Known trade-offs:
unsafe-evalandunsafe-inlineinscript-srcare required by the Next.js compiler. Mitigated by strictframe-ancestors 'none'andbase-uri 'self'. Nonce-based CSP should be explored in future versions.img-src https:allows images from any HTTPS source (needed for user avatars and external content).
For same-origin WebSocket connections, httpOnly cookies are sent automatically during the upgrade handshake. No additional token is needed.
For cross-origin connections (src/context/websocket-provider.tsx):
- Client fetches token from
/api/ws-token(authenticated endpoint, reads httpOnly cookie). - Token appended as query parameter:
wss://api.example.com/ws?token=<jwt>. - Protocol auto-selects
wss:for HTTPS origins,ws:for HTTP.
Trade-off: JWT appears in URL for cross-origin WebSocket (common pattern due to WebSocket API limitations). The /api/ws-token endpoint only returns the token to the same browser session that owns the httpOnly cookie.
Same pattern as WebSocket. Endpoint at /api/auth/sse-token returns access token for Server-Sent Events connections. Auto-refreshes if access token expired.
- OAuth flows: Protected via random
stateparameter stored in httpOnly cookie. - API mutations: Protected by same-origin proxy pattern. All API calls go through
/api/v1/*(same-origin), so browsers enforce same-origin policy automatically. - CSRF token:
generateCsrfToken()insrc/lib/cookies-server.tsgenerates tokens stored in httpOnly, secure, sameSite=strict cookies. The proxy forwardsx-csrf-tokenheaders to the backend.
All forms use Zod schemas (src/features/*/schemas/) for:
- Client-side validation (immediate UX feedback via React Hook Form).
- Server-side validation (Server Actions re-validate before processing).
| Field | Validation |
|---|---|
| Non-empty + valid email format | |
| Password | Minimum 8 characters |
| First/Last Name | 1-50 characters |
| Confirm Password | Must match password field |
| Reset Token | Non-empty string |
| OAuth State | Validated against stored cookie |
strict: trueintsconfig.json-- no implicitany, strict null checks.no-explicit-anyESLint rule enforced (except test files).
Baked into the client bundle at build time. Safe to expose:
| Variable | Default | Purpose |
|---|---|---|
NEXT_PUBLIC_APP_URL |
http://localhost:3000 |
Frontend URL |
NEXT_PUBLIC_AUTH_PROVIDER |
local |
Auth mode (local, oidc, hybrid) |
NEXT_PUBLIC_WS_BASE_URL |
(empty) | WebSocket URL (if cross-origin) |
NEXT_PUBLIC_SSE_BASE_URL |
(empty) | SSE URL (if cross-origin) |
NEXT_PUBLIC_AUTH_COOKIE_NAME |
auth_token |
Access token cookie name |
NEXT_PUBLIC_REFRESH_COOKIE_NAME |
refresh_token |
Refresh token cookie name |
Never exposed to the browser:
| Variable | Default | Purpose |
|---|---|---|
BACKEND_API_URL |
http://localhost:8080 |
Backend API URL |
SECURE_COOKIES |
false |
Secure flag on cookies (overridden to true in production via NODE_ENV check) |
CSRF_SECRET |
(empty) | CSRF token signing key |
COOKIE_MAX_AGE |
604800 (7 days) |
Refresh token cookie lifetime |
ENABLE_TOKEN_REFRESH |
true |
Enable auto token refresh |
TOKEN_REFRESH_BEFORE_EXPIRY |
300 (5 min) |
Refresh trigger threshold |
validateEnv() in next.config.ts validates required variables at build time. Warns on missing optional variables. Skipped during Docker builds (CI=true).
File: Dockerfile
| Layer | Security Measure |
|---|---|
| Base image | node:25-alpine (minimal attack surface) |
| Multi-stage build | Dev dependencies excluded from production image |
| Non-root user | Production runs as nextjs (UID 1001) |
| Standalone output | Minimal file footprint in final image |
| Telemetry | NEXT_TELEMETRY_DISABLED=1 |
| Health check | wget --spider http://localhost:3000/api/health |
| Build args | NEXT_PUBLIC_* vars baked at build, server vars injected at runtime |
Runs on: push/PR to main/develop + weekly (Monday 00:00 UTC)
| Scanner | Type | Severity | Output |
|---|---|---|---|
| CodeQL | SAST | security-and-quality query suite |
GitHub Security tab (SARIF) |
| npm audit | SCA | HIGH+ | Artifact upload (30-day retention) |
| Trivy | Filesystem | CRITICAL, HIGH, MEDIUM | GitHub Security tab (SARIF) |
| ESLint | SAST | All rules | GitHub Security tab (SARIF) |
| Gitleaks | Secrets | All | Optional (requires ENABLE_GITLEAKS + license) |
| Snyk | SCA | All | Optional (requires ENABLE_SNYK + token) |
| Docker Image Scan | Container | CRITICAL, HIGH | Trivy on built image (main branch only) |
| Check | Tool | Blocks PR |
|---|---|---|
| Type safety | tsc --noEmit |
Yes |
| Code quality | ESLint | Yes |
| Formatting | Prettier | Yes |
| Unit tests | Vitest + coverage | Yes |
| Build | next build |
Push only |
Automatically run on git commit:
- TypeScript type checking on all staged files.
- ESLint auto-fix on staged
.ts/.tsxfiles. - Prettier formatting on staged files.
- Weekly updates for npm, GitHub Actions, Docker base images.
- Grouped updates: React, Next.js, testing, linting, Tailwind, Radix UI, TypeScript.
- Major versions blocked by default (security updates still allowed).
- Review assigned to
openctemio/securityteam.
A development-only auth bypass exists in src/lib/dev-auth.ts:
- Enabled only when
NODE_ENV === 'development'. - Uses hardcoded dev credentials (
admin@openctem.io). - Sets a
dev_auth_tokencookie (non-httpOnly) accepted by the middleware. - Completely disabled in production via
NODE_ENVguard.
The API error handler (src/lib/api/error-handler.ts) maps backend errors to user-friendly messages. Backend messages shorter than 100 characters and not containing internal markers are passed through. Sensitive errors (500s, auth failures) are replaced with generic messages.
- Never commit secrets -- Use
.env.local(gitignored), not.env. - Validate all inputs with Zod schemas in both client and server.
- Use Server Components by default to reduce client-side attack surface.
- Use
window.location.href(notrouter.push) after auth changes to ensure cookies are picked up. - No
anytypes -- Useunknownand narrow with type guards. - Run
npm run validatebefore every commit (type-check + lint + format).
- Never store tokens in localStorage or sessionStorage -- Use Zustand store (memory only).
- Never use
dangerouslySetInnerHTMLwithout sanitization. - Never add
NEXT_PUBLIC_prefix to secret environment variables. - Never bypass the API proxy for authenticated requests (except file uploads which use XHR with in-memory token).
- Never hardcode credentials outside of dev-only guarded code.
- Sensitive cookies: Always
httpOnly: true,secure: truein production,sameSite: 'lax'. - Client-accessible cookies: Only for non-sensitive data (locale, theme, tenant display info).
- The
authTokenCookie.set()client-side method is deliberately blocked and logs an error.
| Item | Status | Rationale |
|---|---|---|
CSP unsafe-eval/unsafe-inline |
Required | Next.js compiler dependency. Mitigated by frame-ancestors 'none' and base-uri 'self'. |
| WebSocket token in URL query | Accepted | WebSocket API cannot send custom headers. Token scoped to same session via httpOnly endpoint. |
| Password policy (8 chars minimum) | Intentional | Backend enforces additional policies. Client-side strength meter provides guidance. |
img-src https: wildcard |
Accepted | Required for user avatars and external content. |
- Nonce-based CSP to eliminate
unsafe-inline/unsafe-eval - Environment-conditional CSP
connect-src(strip localhost in production) - Upgrade CSRF token generation to
crypto.randomBytes() - Client-side rate limiting for login attempts (config exists at
SECURITY_CONFIG.MAX_LOGIN_ATTEMPTS: 5but not yet enforced) - Subresource Integrity (SRI) for external resources
- OWASP Top 10 (actively addressed)
- CWE/SANS Top 25 (actively addressed)
- SOC 2 Type II (planned)
- ISO 27001 (planned)
- Security Team: security@openctem.io
- General Issues: https://github.com/openctemio/ui/issues