Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.docker
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ DATABASE_PATH=/opt/pabawi/data/pabawi.db

# JWT Secret for authentication (REQUIRED in production)
# Generate with: openssl rand -base64 32
JWT_SECRET=your-secure-random-secret-here # pragma: allowlist secret
# Must be at least 32 chars of random entropy. Placeholder strings (e.g.
# "your-secure-random-secret-here", "change-me") are rejected at startup.
JWT_SECRET= # pragma: allowlist secret

# CORS allowed origins (comma-separated)
# CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
Expand Down
208 changes: 208 additions & 0 deletions .kiro/html/202605221634-security-remediation-hardening.html

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"kirograph": {
"command": "kirograph",
"args": [
"serve",
"--mcp"
]
}
}
}
107 changes: 107 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,112 @@
# Changelog

## [1.3.0] - 2026-05-23

### Security — breaking for operators

**Action required before upgrade:**

- **`JWT_SECRET` must be ≥ 32 characters and not a placeholder.** The schema
now rejects strings shorter than 32 chars and matches the documented
placeholders (`your-secure-random-secret-here`, `change-me`, etc.). Servers
with an invalid secret refuse to boot. Rotate with
`JWT_SECRET=$(openssl rand -base64 32)` if you are still on a placeholder.
- **`DELETE /api/inventory/:id` now requires the lifecycle bearer token.**
Any non-Pabawi client (custom scripts, cron jobs) calling the destroy
endpoint must pass `Authorization: Bearer <PABAWI_LIFECYCLE_TOKEN>`.
- **SSE `?token=` URL fallback removed.** Any non-Pabawi SSE client (custom
dashboards, scripts) must obtain a single-use ticket via
`POST /api/executions/:id/stream-ticket` and pass it as `?ticket=…` instead.
The full JWT in the URL was the leak: it landed in access logs, browser
history, and proxy caches.
- **Refresh-token rotation is now enforced.** Every successful
`POST /api/auth/refresh` invalidates the inbound refresh token and returns
a new one. Presenting a previously-used refresh token triggers family
revocation (all the user's tokens are killed) — clients must always store
and use the latest `refreshToken` from the most recent response.
- **Permanent account lockout removed.** Only the temporary 15-min lockout
remains. The previous "permanent after 10 attempts" path was a trivial
self-service DoS against any legitimate user. The `users:admin` permission
can unlock a temporarily-locked account via the new
`POST /api/users/:id/unlock` endpoint.

### Security — non-breaking

- `isAdmin` removed from the `POST /api/users` and `PUT /api/users/:id` body
schemas. Elevation now goes through the dedicated
`PUT /api/users/:id/admin-status` endpoint, gated by `users:admin`; admins
cannot change their own admin status.
- `users:write` (without `users:admin`) is now restricted to `email`/`isActive`
changes on non-admin targets. Password and name changes on another user
require `users:admin`.
- Strict task-name and command-shape validation on Bolt-targeted argv
(`POST /api/tasks`, `POST /api/nodes/:id/command`). Leading `-` and unsafe
identifiers are rejected upstream and again at the spawn boundary.
- PuppetDB/Puppetserver routes now validate `certname` (NODE_ID pattern) and
`hash` (40–128 hex chars). PQL is composed via `JSON.stringify`; URL paths
are wrapped in `encodeURIComponent`.
- Bearer-token equality checks switched to constant-time comparisons (MCP +
lifecycle).
- Dedup middleware cache key now includes `userId` to prevent cross-user
cache leakage on shared GET routes.
- Password fields (`password`, `currentPassword`, `newPassword`,
`confirmPassword`) bypass the central input sanitizer — trimming/truncating
them silently produced auth failures.
- `/change-password`'s `currentPassword` mis-match feeds the brute-force
pipeline (5 wrong attempts → temporary lockout) so it can't be used as an
un-rate-limited oracle.
- JWT tokens now carry `iss=pabawi` / `aud=pabawi` and verification refuses
tokens missing those claims.
- Hiera path resolution rejects fact-driven `..` escapes outside the control
repo root.
- Search filters (UserService, RoleService, GroupService) escape LIKE
wildcards in user input.
- `POST /api/setup/initialize` is rate-limited (10 req / 15min / IP) and now
detects post-creation duplicate admins from a concurrent race.
- Error responses redact raw `error.message` for non-expert callers (paths,
env values, external stderr no longer leak).
- `crypto.randomUUID()` for expert-mode correlation IDs.

### Added

- **PostgreSQL backend support.** Set `DB_TYPE=postgres` and `DATABASE_URL`
to run on PostgreSQL instead of SQLite; both share one schema and code path
(application SQL uses `?` placeholders, rewritten to `$n` for PostgreSQL).
`docker-compose.yml` gains a profile-gated `postgres` service. See
[docs/configuration.md](docs/configuration.md#database).
- `PUT /api/users/:id/admin-status` — gated by `users:admin`, refuses
self-modification.
- `POST /api/users/:id/unlock` — clears temporary lockout + cumulative
counter, audit-logged.
- `BoltCommandWhitelistService` (renamed; old `CommandWhitelistService`
remains as a deprecated alias).
- Regression test suite at
`backend/test/security/jazzy-launching-wombat-regressions.test.ts` pinning
the contracts for A2, B1, B2, B4, C3, C7, C8.
- PostgreSQL test harness under `scripts/`: `docker-postgres-test.sh`
(start / stop / destroy / status / logs subcommands) plus a dedicated
`docker-postgres-test.compose.yml` and `docker-postgres-test.env`. Builds
the app from local source into an isolated Compose project
(`pabawi-postgres-test`) on host ports 3001 / 5433 so it does not collide
with the main stack. Useful while the published `:latest` image lags
behind PostgreSQL support.

### Fixed

- `scripts/docker-entrypoint.sh` no longer creates the SQLite data file when
`DB_TYPE=postgres`. The previous unconditional `touch "$DATABASE_PATH"`
caused the container to exit with `cannot touch: No such file or
directory` whenever a non-existent path was set under the postgres
backend. SQLite-mode behaviour is unchanged (default when `DB_TYPE` is
unset).

### Deferred (separate PRs)

- Refresh-token move to `HttpOnly` cookie + CSRF (H4).
- `sqlite3 → better-sqlite3` migration or `tar` override (M10).
- Operator action: rotate AWS access key, Proxmox token, `JWT_SECRET`, and
`MCP_AUTH_TOKEN` that were transmitted during the review.

## [1.2.0] - 2026-05-17

### Added
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ ARG BUILDPLATFORM
# Add metadata labels
LABEL org.opencontainers.image.title="Pabawi"
LABEL org.opencontainers.image.description="Puppet Ansible Bolt Awesome Web Interface"
LABEL org.opencontainers.image.version="1.2.0"
LABEL org.opencontainers.image.version="1.3.0"
LABEL org.opencontainers.image.vendor="example42"
LABEL org.opencontainers.image.source="https://github.com/example42/pabawi"

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.alpine
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ ARG BUILDPLATFORM
# Add metadata labels
LABEL org.opencontainers.image.title="Pabawi"
LABEL org.opencontainers.image.description="Puppet Ansible Bolt Awesome Web Interface"
LABEL org.opencontainers.image.version="1.2.0"
LABEL org.opencontainers.image.version="1.3.0"
LABEL org.opencontainers.image.vendor="example42"
LABEL org.opencontainers.image.source="https://github.com/example42/pabawi"

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.ubuntu
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ ARG BUILDPLATFORM
# Add metadata labels
LABEL org.opencontainers.image.title="Pabawi"
LABEL org.opencontainers.image.description="Puppet Ansible Bolt Awesome Web Interface"
LABEL org.opencontainers.image.version="1.2.0"
LABEL org.opencontainers.image.version="1.3.0"
LABEL org.opencontainers.image.vendor="example42"
LABEL org.opencontainers.image.source="https://github.com/example42/pabawi"

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ pabawi/
│ ├── mcp/ # Embedded MCP server and tool handlers
│ ├── routes/ # Express route factories
│ ├── middleware/ # JWT, RBAC, rate limiting, security headers
│ ├── database/ # SQLite + migrations
│ ├── database/ # SQLite / PostgreSQL adapters + migrations
│ ├── errors/ # Typed error classes
│ └── validation/ # Zod request schemas
├── docs/ # Documentation
Expand Down Expand Up @@ -269,4 +269,4 @@ For help: enable expert mode for diagnostics, or [open a GitHub issue](https://g

## Acknowledgments

Pabawi builds on: [Puppet/OpenVox](https://puppet.com), [Bolt](https://puppet.com/docs/bolt), [PuppetDB](https://puppet.com/docs/puppetdb), [Svelte 5](https://svelte.dev), [Node.js](https://nodejs.org), [TypeScript](https://www.typescriptlang.org), [SQLite](https://sqlite.org). Thanks to all contributors and the Puppet community.
Pabawi builds on: [Puppet/OpenVox](https://puppet.com), [Bolt](https://puppet.com/docs/bolt), [PuppetDB](https://puppet.com/docs/puppetdb), [Svelte 5](https://svelte.dev), [Node.js](https://nodejs.org), [TypeScript](https://www.typescriptlang.org), [SQLite](https://sqlite.org), [PostgreSQL](https://www.postgresql.org). Thanks to all contributors and the Puppet community.
15 changes: 14 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,22 @@ MCP_ENABLED=false
# Generate with: openssl rand -base64 32
JWT_SECRET=your-secure-random-secret-here # pragma: allowlist secret

# Database Path
# Database backend: "sqlite" (default) or "postgres"
DB_TYPE=sqlite

# SQLite database path (used when DB_TYPE=sqlite)
DATABASE_PATH=./data/pabawi.db

# PostgreSQL connection URL (REQUIRED when DB_TYPE=postgres)
# DATABASE_URL=postgres://pabawi:pabawi@localhost:5432/pabawi

# PostgreSQL container settings for the docker-compose "postgres" profile.
# Start it with: docker compose --profile postgres up
# POSTGRES_USER=pabawi
# POSTGRES_PASSWORD=pabawi
# POSTGRES_DB=pabawi
# POSTGRES_PORT=5432

# CORS (comma-separated origins; defaults to localhost dev ports)
# CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000

Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.2.0",
"version": "1.3.0",
"description": "Backend API server for Pabawi",
"main": "dist/server.js",
"scripts": {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/config/ConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,8 @@ export class ConfigService {
: undefined,
logLevel: process.env.LOG_LEVEL,
databasePath: process.env.DATABASE_PATH,
dbType: process.env.DB_TYPE,
databaseUrl: process.env.DATABASE_URL,
corsAllowedOrigins: process.env.CORS_ALLOWED_ORIGINS
? process.env.CORS_ALLOWED_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean)
: undefined,
Expand Down
10 changes: 9 additions & 1 deletion backend/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,20 @@ export const AppConfigSchema = z.object({
port: z.number().int().positive().default(3000),
host: z.string().default("localhost"),
boltProjectPath: z.string().default(process.cwd()),
jwtSecret: z.string().min(1, "JWT_SECRET is required"),
jwtSecret: z
.string()
.min(32, "JWT_SECRET must be at least 32 characters of random entropy")
.refine(
(s) => !/your-secure-random-secret-here/i.test(s) && !/change[-_ ]?me/i.test(s),
"JWT_SECRET must not be a placeholder string (e.g. 'your-secure-random-secret-here', 'change-me')",
),
lifecycleToken: z.string().default(""),
commandWhitelist: WhitelistConfigSchema,
executionTimeout: z.number().int().positive().default(300000), // 5 minutes
logLevel: z.enum(["error", "warn", "info", "debug"]).default("info"),
databasePath: z.string().default("./data/pabawi.db"),
dbType: z.enum(["sqlite", "postgres"]).default("sqlite"),
databaseUrl: z.string().optional(),
corsAllowedOrigins: z.array(z.string().url()).default([]),
Comment on lines 363 to 381
packageTasks: z.array(PackageTaskConfigSchema).default([
{
Expand Down
27 changes: 18 additions & 9 deletions backend/src/database/AdapterFactory.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
import type { DatabaseAdapter } from "./DatabaseAdapter";

export interface AdapterFactoryConfig {
/** Filesystem path for the SQLite database (used when dbType is "sqlite"). */
databasePath: string;
/** Which database backend to create. Defaults to "sqlite" when omitted. */
dbType?: "sqlite" | "postgres";
/** PostgreSQL connection URL. Required when dbType is "postgres". */
databaseUrl?: string;
}

/**
* Create the appropriate DatabaseAdapter based on environment configuration.
* Create the appropriate DatabaseAdapter from validated configuration.
*
* - DB_TYPE="sqlite" or unset → SQLiteAdapter
* - DB_TYPE="postgres" → PostgresAdapter (requires DATABASE_URL)
* Configuration is resolved and validated by ConfigService/Zod and passed in
* explicitly — this factory never reads `process.env` directly.
*
* - dbType "sqlite" (or omitted) → SQLiteAdapter
* - dbType "postgres" → PostgresAdapter (requires databaseUrl)
*/
export async function createDatabaseAdapter(config: AdapterFactoryConfig): Promise<DatabaseAdapter> {
const dbType = process.env.DB_TYPE ?? "sqlite";
export async function createDatabaseAdapter(
config: AdapterFactoryConfig,
): Promise<DatabaseAdapter> {
const dbType = config.dbType ?? "sqlite";

if (dbType === "postgres") {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
if (!config.databaseUrl) {
throw new Error(
"DATABASE_URL environment variable is required when DB_TYPE is 'postgres'"
"DATABASE_URL is required when DB_TYPE is 'postgres'",
);
}
const { PostgresAdapter } = await import("./PostgresAdapter");
return new PostgresAdapter(databaseUrl);
return new PostgresAdapter(config.databaseUrl);
}

const { SQLiteAdapter } = await import("./SQLiteAdapter");
Expand Down
11 changes: 7 additions & 4 deletions backend/src/database/DatabaseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ export interface DatabaseAdapter {
/** Returns true if the database connection is open. */
isConnected(): boolean;

/** Returns the SQL dialect of this adapter. */
/**
* Returns the SQL dialect of this adapter.
*
* Parameter placeholders are always written as `?`; PostgresAdapter rewrites
* them to `$n` at query time. Use this only for genuine dialect divergences
* (e.g. `LIKE` vs `ILIKE`).
*/
getDialect(): "sqlite" | "postgres";

/** Returns the parameter placeholder for the given 1-based index. */
getPlaceholder(index: number): string;
}
16 changes: 14 additions & 2 deletions backend/src/database/DatabaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ import { MigrationRunner } from "./MigrationRunner";
export class DatabaseService {
private adapter: DatabaseAdapter | null = null;
private databasePath: string;
private dbType: "sqlite" | "postgres";
private databaseUrl?: string;

constructor(databasePath: string) {
constructor(
databasePath: string,
dbType: "sqlite" | "postgres" = "sqlite",
databaseUrl?: string,
) {
this.databasePath = databasePath;
this.dbType = dbType;
this.databaseUrl = databaseUrl;
}

/**
Expand All @@ -20,7 +28,11 @@ export class DatabaseService {
try {
// Create adapter via factory and initialize (the SQLite adapter creates
// its parent directory internally; Postgres does not need filesystem setup)
this.adapter = await createDatabaseAdapter({ databasePath: this.databasePath });
this.adapter = await createDatabaseAdapter({
databasePath: this.databasePath,
dbType: this.dbType,
databaseUrl: this.databaseUrl,
});
await this.adapter.initialize();

// Initialize schema
Expand Down
Loading
Loading