feat: production hardening — robots, allowedOrigins, container non-root, rate limits, CLI bootstrap

robots.ts + metadata.robots blocks indexing.
serverActions.allowedOrigins gates cross-origin Server Action posts.
Bot + web Dockerfiles add a non-root 'app' user (uid 1000) with
chmod 700 on /data/sessions.
sendTestAction grows a per-group rate limit (3/60s).
resumeReminderRunAction + cancelReminderRunAction get a per-IP
rate limit (30/10s).
.env.example documents every required key.
packages/db/src/scripts/{set-password,create-user}.ts + thin shell
wrappers in scripts/ — first admin sets their password via
./scripts/set-password.sh admin before signing in.
This commit is contained in:
yiekheng 2026-05-10 18:05:34 +08:00
parent 67091c294a
commit b29d137c84
12 changed files with 155 additions and 0 deletions

27
apps/web/.env.example Normal file
View File

@ -0,0 +1,27 @@
# Required
DATABASE_URL=postgres://user:pass@host:5432/dbname
# Auth — sign cookies. 64+ random chars. Generate via scripts/gen_auth_secret.sh.
AUTH_SECRET=replace-me
# Bump to invalidate all live sessions instantly. Leave at 1 normally.
OPERATOR_TOKEN_VERSION=1
# File-storage paths inside the bot container
DATA_DIR=/data
SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media
# Bot fan-out tuning (see apps/bot/src/env.ts)
BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=info
BOT_FIRE_CONCURRENCY=8
BOT_GROUP_CONCURRENCY=3
BOT_MAX_SEND_PER_MINUTE=40
# Web
WEB_PORT=9000
# Seed (runs once via scripts/db.sh seed)
SEED_OPERATOR_USERNAME=admin
SEED_OPERATOR_NAME=Operator

View File

@ -21,6 +21,7 @@ const nextConfig: NextConfig = {
experimental: { experimental: {
typedRoutes: true, typedRoutes: true,
serverActions: { serverActions: {
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
// Default Server Action body limit is 1 MB — way under WhatsApp's // Default Server Action body limit is 1 MB — way under WhatsApp's
// 100 MB document cap. Lifted to 100 MB so document uploads reach // 100 MB document cap. Lifted to 100 MB so document uploads reach
// the action; the per-kind WhatsApp validator // the action; the per-kind WhatsApp validator

View File

@ -33,6 +33,12 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
} }
const groupId = parsed.data.groupId;
const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 });
if (groupRl.limited) {
return { ok: false, error: "Too many tests for this group. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const group = await db.query.whatsappGroups.findFirst({ const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, parsed.data.groupId), where: (g, { eq }) => eq(g.id, parsed.data.groupId),

View File

@ -563,6 +563,12 @@ export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string
export async function resumeReminderRunAction(input: { export async function resumeReminderRunAction(input: {
runId: string; runId: string;
}): Promise<ResumeReminderRunResult> { }): Promise<ResumeReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input); const parsed = runIdSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
@ -613,6 +619,12 @@ export type CancelReminderRunResult = { ok: true } | { ok: false; error: string
export async function cancelReminderRunAction(input: { export async function cancelReminderRunAction(input: {
runId: string; runId: string;
}): Promise<CancelReminderRunResult> { }): Promise<CancelReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input); const parsed = runIdSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {

View File

@ -10,6 +10,7 @@ export const metadata: Metadata = {
title: "cm WhatsApp Bot", title: "cm WhatsApp Bot",
description: "Self-hosted WhatsApp reminder bot", description: "Self-hosted WhatsApp reminder bot",
applicationName: "cm WhatsApp Bot", applicationName: "cm WhatsApp Bot",
robots: { index: false, follow: false },
// PWA wiring: the manifest comes from the dynamic route at // PWA wiring: the manifest comes from the dynamic route at
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is // src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
// emitted from public/, and `appleWebApp.capable` lets iOS treat the // emitted from public/, and `appleWebApp.capable` lets iOS treat the

View File

@ -0,0 +1,5 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return { rules: [{ userAgent: "*", disallow: "/" }] };
}

View File

@ -26,5 +26,11 @@ COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/apps/bot /app/apps/bot COPY --from=build /app/apps/bot /app/apps/bot
COPY --from=build /app/packages/db /app/packages/db COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/shared /app/packages/shared COPY --from=build /app/packages/shared /app/packages/shared
RUN addgroup -g 1000 app && \
adduser -D -u 1000 -G app -s /sbin/nologin app && \
mkdir -p /data/sessions /data/media /app && \
chown -R app:app /app /data && \
chmod 700 /data/sessions
USER app
EXPOSE 8081 EXPOSE 8081
CMD ["node", "apps/bot/dist/index.js"] CMD ["node", "apps/bot/dist/index.js"]

View File

@ -29,5 +29,9 @@ ENV HOSTNAME=0.0.0.0
COPY --from=build /app/apps/web/.next/standalone ./ COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public COPY --from=build /app/apps/web/public ./apps/web/public
RUN addgroup -g 1000 app && \
adduser -D -u 1000 -G app -s /sbin/nologin app && \
chown -R app:app /app
USER app
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/web/server.js"] CMD ["node", "apps/web/server.js"]

View File

@ -0,0 +1,42 @@
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { createClient } from "../index.js";
async function main() {
const username = process.argv[2];
const role = process.argv[3];
if (!username || (role !== "admin" && role !== "user")) {
console.error("Usage: create-user <username> <admin|user>");
process.exit(2);
}
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL not set");
process.exit(2);
}
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
process.stdout.write("Password: ");
const password = await rl.question("");
rl.close();
process.stdout.write("\n");
if (password.length < 10) {
console.error("Password must be at least 10 characters.");
process.exit(2);
}
const hash = await bcrypt.hash(password, 12);
const { db, pool } = createClient(url);
await db.execute(
sql`INSERT INTO operators (username, password_hash, display_name, role, default_timezone)
VALUES (${username}, ${hash}, ${username}, ${role}, 'Asia/Kuala_Lumpur')`,
);
await pool.end();
console.log(`Created ${role} ${username}.`);
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,45 @@
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { createClient } from "../index.js";
async function main() {
const username = process.argv[2];
if (!username) {
console.error("Usage: set-password <username>");
process.exit(2);
}
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL not set");
process.exit(2);
}
// Silenced password prompt.
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
process.stdout.write("Password: ");
const password = await rl.question("");
rl.close();
process.stdout.write("\n");
if (password.length < 10) {
console.error("Password must be at least 10 characters.");
process.exit(2);
}
const hash = await bcrypt.hash(password, 12);
const { db, pool } = createClient(url);
const result = await db.execute(
sql`UPDATE operators SET password_hash = ${hash} WHERE lower(username) = lower(${username}) RETURNING id`,
);
await pool.end();
if (result.rows.length === 0) {
console.error(`No user with username ${username}`);
process.exit(1);
}
console.log("Password updated.");
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

3
scripts/create-user.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/create-user.ts "$@"

3
scripts/set-password.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/set-password.ts "$@"