Ratelimit

This commit is contained in:
2026-04-12 18:13:26 +02:00
parent dbfa7560cf
commit 6f9f46b2d0
8 changed files with 535 additions and 11 deletions

View File

@@ -1,5 +1,25 @@
npm install #!/usr/bin/env bash
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.4 . set -euo pipefail
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.4
kubectl rollout restart deployment pieced-portal -n pieced-system
VERSION="${1:-}"
REGISTRY="registry.c5ai.ch/pieced/pieced-portal"
if [[ -z "$VERSION" ]]; then
echo "Usage: ./buildanddeploy.sh <version>"
echo "Example: ./buildanddeploy.sh 0.2.0"
exit 1
fi
echo "Building pieced-portal:${VERSION}..."
npm install
docker build -t "${REGISTRY}:${VERSION}" .
docker push "${REGISTRY}:${VERSION}"
echo ""
echo "✓ Pushed ${REGISTRY}:${VERSION}"
echo ""
echo "Next steps:"
echo " 1. Update image tag in pieced-gitops/apps/portal/deployment.yaml:"
echo " image: ${REGISTRY}:${VERSION}"
echo " 2. git commit + push to pieced-gitops"
echo " 3. ArgoCD syncs automatically"

View File

@@ -0,0 +1,4 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | **BLOCKED** | ❌ FAIL |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 19
- **Failed**: 2
- **Date**: 2026-04-12 15:09:45 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | allowed | ✅ PASS |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 20
- **Failed**: 1
- **Date**: 2026-04-12 15:16:10 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

View File

@@ -0,0 +1,37 @@
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
| Cross-tenant: alpha→testfirma:18789 | tenant-alpha | openclaw.tenant-testfirma:18789 | block | blocked | ✅ PASS |
| Cross-tenant: testfirma→alpha:18789 | tenant-testfirma | openclaw.tenant-alpha:18789 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:18793 | tenant-alpha | openclaw.tenant-testfirma:18793 | block | blocked | ✅ PASS |
| Cross-tenant: alpha→testfirma:9090 | tenant-alpha | openclaw.tenant-testfirma:9090 | block | blocked | ✅ PASS |
| Tenant→OpenBao | tenant-alpha | openbao:8200 | block | blocked | ✅ PASS |
| Tenant→ZITADEL (svc) | tenant-alpha | zitadel:8080 | block | blocked | ✅ PASS |
| Tenant→Portal | tenant-alpha | pieced-portal:3000 | block | blocked | ✅ PASS |
| Tenant→Portal DB | tenant-alpha | portal-db-rw:5432 | block | blocked | ✅ PASS |
| Tenant→ArgoCD | tenant-alpha | argocd-server:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-alpha | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→K8s API | tenant-testfirma | kubernetes.default:443 | block | blocked | ✅ PASS |
| Tenant→DNS | tenant-alpha | kube-dns | allow | allowed | ✅ PASS |
| Tenant→LiteLLM | tenant-alpha | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Tenant→world:443 | tenant-alpha | httpbin.org:443 | allow | allowed | ✅ PASS |
| Platform→OpenBao | pieced-system | openbao:8200 | allow | allowed | ✅ PASS |
| Platform→ZITADEL | pieced-system | zitadel:8080 | allow | allowed | ✅ PASS |
| Platform→K8s API | pieced-system | kubernetes.default:443 | allow | allowed | ✅ PASS |
| Platform→LiteLLM | pieced-system | litellm.inference:4000 | allow | allowed | ✅ PASS |
| Platform→Portal DB | pieced-system | portal-db-rw:5432 | allow | **BLOCKED** | ❌ FAIL |
| Tenant→Operator | tenant-alpha | pieced-operator:8080 | block | blocked | ✅ PASS |
| Tenant→metadata endpoint | tenant-alpha | 169.254.169.254 | block | blocked | ✅ PASS |
## Summary
- **Passed**: 20
- **Failed**: 1
- **Date**: 2026-04-12 15:19:15 UTC
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume `litellm.inference.svc:4000`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.

283
scripts/cilium-audit.sh Normal file
View File

@@ -0,0 +1,283 @@
#!/usr/bin/env bash
# ============================================================================
# PieCed IT — Session 7.1: Cilium Network Policy Audit
# ============================================================================
#
# Prerequisites:
# - kubectl configured for the cluster
# - Existing pods:
# tenant-alpha/openclaw-0 (3 containers)
# tenant-testfirma/openclaw-0 (3 containers)
# pieced-system/pieced-portal-* (1 container)
#
# This script deploys temporary netshoot pods (they have curl, nslookup, etc.)
# into each namespace, runs the tests, then cleans up.
#
# Usage:
# chmod +x cilium-audit.sh
# ./cilium-audit.sh
# ============================================================================
set -euo pipefail
RED='\033[0;31m'
GRN='\033[0;32m'
YLW='\033[1;33m'
RST='\033[0m'
PASS=0
FAIL=0
WARN=0
# Results file
RESULTS_FILE="cilium-audit-results-$(date +%Y%m%d-%H%M%S).md"
log_header() {
echo ""
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo -e "${YLW} $1${RST}"
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
}
log_result() {
local test_name="$1"
local from_ns="$2"
local to_target="$3"
local expected="$4" # "block" or "allow"
local actual="$5" # exit code from curl/nslookup: 0=success, non-0=fail
if [[ "$expected" == "block" ]]; then
if [[ "$actual" -ne 0 ]]; then
echo -e " ${GRN}✓ PASS${RST} [$from_ns$to_target] $test_name (blocked as expected)"
PASS=$((PASS + 1))
echo "| $test_name | $from_ns | $to_target | block | blocked | ✅ PASS |" >> "$RESULTS_FILE"
else
echo -e " ${RED}✗ FAIL${RST} [$from_ns$to_target] $test_name (SHOULD BE BLOCKED but succeeded!)"
FAIL=$((FAIL + 1))
echo "| $test_name | $from_ns | $to_target | block | **ALLOWED** | ❌ FAIL |" >> "$RESULTS_FILE"
fi
else
if [[ "$actual" -eq 0 ]]; then
echo -e " ${GRN}✓ PASS${RST} [$from_ns$to_target] $test_name (allowed as expected)"
PASS=$((PASS + 1))
echo "| $test_name | $from_ns | $to_target | allow | allowed | ✅ PASS |" >> "$RESULTS_FILE"
else
echo -e " ${RED}✗ FAIL${RST} [$from_ns$to_target] $test_name (SHOULD BE ALLOWED but blocked!)"
FAIL=$((FAIL + 1))
echo "| $test_name | $from_ns | $to_target | allow | **BLOCKED** | ❌ FAIL |" >> "$RESULTS_FILE"
fi
fi
}
# ----------------------------------------------------------------------------
# Deploy netshoot pods
# ----------------------------------------------------------------------------
deploy_netshoot() {
local ns="$1"
local name="netshoot-audit"
echo " Deploying netshoot in $ns..."
kubectl run "$name" -n "$ns" \
--image=nicolaka/netshoot \
--restart=Never \
--labels="app=netshoot-audit" \
--command -- sleep 600 2>/dev/null || true
kubectl wait --for=condition=Ready pod/"$name" -n "$ns" --timeout=60s
}
cleanup_netshoot() {
echo ""
echo "Cleaning up netshoot pods..."
for ns in tenant-alpha tenant-testfirma pieced-system; do
kubectl delete pod netshoot-audit -n "$ns" --ignore-not-found --wait=false 2>/dev/null || true
done
echo "Done."
}
# Clean up on exit
trap cleanup_netshoot EXIT
# Run a command in netshoot pod, return exit code
# Uses --connect-timeout 5 for curl, timeout 5 for nslookup
run_in() {
local ns="$1"
shift
kubectl exec -n "$ns" netshoot-audit -- "$@" >/dev/null 2>&1
return $?
}
# ============================================================================
# Start
# ============================================================================
echo ""
echo "PieCed IT — Cilium Network Policy Audit"
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo ""
# Initialize results markdown
cat > "$RESULTS_FILE" <<'EOF'
# Cilium Network Policy Audit Results
| Test | From | To | Expected | Actual | Result |
|------|------|----|----------|--------|--------|
EOF
# Deploy netshoot pods
log_header "Deploying audit pods"
deploy_netshoot tenant-alpha
deploy_netshoot tenant-testfirma
deploy_netshoot pieced-system
# ============================================================================
# SECTION 1: Tenant-to-Tenant Isolation
# ============================================================================
log_header "1. Tenant-to-Tenant Isolation"
# tenant-alpha → tenant-testfirma OpenClaw (port 18789)
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:18789 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:18789" "tenant-alpha" "openclaw.tenant-testfirma:18789" "block" "$rc"
# tenant-testfirma → tenant-alpha OpenClaw (port 18789)
run_in tenant-testfirma curl -s --connect-timeout 5 http://openclaw.tenant-alpha.svc:18789 && rc=0 || rc=$?
log_result "Cross-tenant: testfirma→alpha:18789" "tenant-testfirma" "openclaw.tenant-alpha:18789" "block" "$rc"
# Cross-tenant on other OpenClaw ports
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:18793 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:18793" "tenant-alpha" "openclaw.tenant-testfirma:18793" "block" "$rc"
run_in tenant-alpha curl -s --connect-timeout 5 http://openclaw.tenant-testfirma.svc:9090 && rc=0 || rc=$?
log_result "Cross-tenant: alpha→testfirma:9090" "tenant-alpha" "openclaw.tenant-testfirma:9090" "block" "$rc"
# ============================================================================
# SECTION 2: Tenant → Platform Services (must be blocked except LiteLLM)
# ============================================================================
log_header "2. Tenant → Platform Services"
# OpenBao
run_in tenant-alpha curl -s --connect-timeout 5 http://openbao.openbao-system.svc:8200/v1/sys/health && rc=0 || rc=$?
log_result "Tenant→OpenBao" "tenant-alpha" "openbao:8200" "block" "$rc"
# ZITADEL (direct svc, not via ingress)
run_in tenant-alpha curl -s --connect-timeout 5 http://zitadel.zitadel.svc:8080/debug/healthz && rc=0 || rc=$?
log_result "Tenant→ZITADEL (svc)" "tenant-alpha" "zitadel:8080" "block" "$rc"
# Portal
run_in tenant-alpha curl -s --connect-timeout 5 http://pieced-portal.pieced-system.svc:3000 && rc=0 || rc=$?
log_result "Tenant→Portal" "tenant-alpha" "pieced-portal:3000" "block" "$rc"
# Portal DB
run_in tenant-alpha curl -s --connect-timeout 5 http://portal-db-rw.pieced-system.svc:5432 && rc=0 || rc=$?
log_result "Tenant→Portal DB" "tenant-alpha" "portal-db-rw:5432" "block" "$rc"
# ArgoCD
run_in tenant-alpha curl -sk --connect-timeout 5 https://argocd-server.argocd.svc:443 && rc=0 || rc=$?
log_result "Tenant→ArgoCD" "tenant-alpha" "argocd-server:443" "block" "$rc"
# ============================================================================
# SECTION 3: Tenant → K8s API Server (must be blocked)
# ============================================================================
log_header "3. Tenant → K8s API Server"
run_in tenant-alpha curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Tenant→K8s API" "tenant-alpha" "kubernetes.default:443" "block" "$rc"
# Also test from the other tenant
run_in tenant-testfirma curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Tenant→K8s API" "tenant-testfirma" "kubernetes.default:443" "block" "$rc"
# ============================================================================
# SECTION 4: Tenant → Allowed Paths (must succeed)
# ============================================================================
log_header "4. Tenant → Allowed Paths"
# DNS resolution
run_in tenant-alpha nslookup -timeout=5 google.com && rc=0 || rc=$?
log_result "Tenant→DNS" "tenant-alpha" "kube-dns" "allow" "$rc"
# LiteLLM (adjust namespace if different — check your actual LiteLLM svc namespace)
# Based on .env.example: LITELLM_INTERNAL_URL=http://litellm.inference.svc:4000
run_in tenant-alpha curl -s --connect-timeout 5 http://litellm.inference.svc:4000/health && rc=0 || rc=$?
log_result "Tenant→LiteLLM" "tenant-alpha" "litellm.inference:4000" "allow" "$rc"
# External HTTPS (world:443)
run_in tenant-alpha curl -s --connect-timeout 5 https://httpbin.org/status/200 && rc=0 || rc=$?
log_result "Tenant→world:443" "tenant-alpha" "httpbin.org:443" "allow" "$rc"
# ============================================================================
# SECTION 5: Platform Pod → Platform Services (must succeed)
# ============================================================================
log_header "5. Platform → Platform Services"
# Platform → OpenBao
run_in pieced-system curl -s --connect-timeout 5 http://openbao.openbao.svc:8200/v1/sys/health && rc=0 || rc=$?
log_result "Platform→OpenBao" "pieced-system" "openbao:8200" "allow" "$rc"
# Platform → ZITADEL
run_in pieced-system curl -s --connect-timeout 5 http://zitadel.zitadel.svc:8080/debug/healthz && rc=0 || rc=$?
log_result "Platform→ZITADEL" "pieced-system" "zitadel:8080" "allow" "$rc"
# Platform → K8s API
run_in pieced-system curl -sk --connect-timeout 5 https://kubernetes.default.svc:443/version && rc=0 || rc=$?
log_result "Platform→K8s API" "pieced-system" "kubernetes.default:443" "allow" "$rc"
# Platform → LiteLLM
run_in pieced-system curl -s --connect-timeout 5 http://litellm.inference.svc:4000/health && rc=0 || rc=$?
log_result "Platform→LiteLLM" "pieced-system" "litellm.inference:4000" "allow" "$rc"
# Platform → Portal DB (internal connectivity)
run_in pieced-system curl -s --connect-timeout 5 http://portal-db-rw.pieced-system.svc:5432 && rc=0 || rc=$?
log_result "Platform→Portal DB" "pieced-system" "portal-db-rw:5432" "allow" "$rc"
# ============================================================================
# SECTION 6: Reverse — Tenant → Platform Pod (must be blocked)
# ============================================================================
log_header "6. Tenant → Platform Pods (reverse check)"
# Tenant → operator
run_in tenant-alpha curl -s --connect-timeout 5 http://pieced-operator.pieced-system.svc:8080 && rc=0 || rc=$?
log_result "Tenant→Operator" "tenant-alpha" "pieced-operator:8080" "block" "$rc"
# ============================================================================
# SECTION 7: Metadata / Edge Cases
# ============================================================================
log_header "7. Edge Cases"
# Cloud metadata endpoint (should be unreachable on bare metal, but verify)
run_in tenant-alpha curl -s --connect-timeout 3 http://169.254.169.254/latest/meta-data/ && rc=0 || rc=$?
log_result "Tenant→metadata endpoint" "tenant-alpha" "169.254.169.254" "block" "$rc"
# ============================================================================
# Summary
# ============================================================================
echo ""
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo -e "${YLW} SUMMARY${RST}"
echo -e "${YLW}═══════════════════════════════════════════════════${RST}"
echo ""
echo -e " ${GRN}Passed: $PASS${RST}"
echo -e " ${RED}Failed: $FAIL${RST}"
echo ""
# Append summary to results file
cat >> "$RESULTS_FILE" <<EOF
## Summary
- **Passed**: $PASS
- **Failed**: $FAIL
- **Date**: $(date -u '+%Y-%m-%d %H:%M:%S UTC')
## Notes
- DNS exfiltration: DNS is allowed for tenants (required for egress). DNS tunneling is a theoretical risk — acceptable for pilot. Consider Cilium DNS-aware policies post-pilot.
- LiteLLM namespace: Tests assume \`litellm.inference.svc:4000\`. Adjust if your LiteLLM is in a different namespace.
- K8s API blocking: If this test fails, you need an explicit CiliumClusterwideNetworkPolicy denying egress to the API server CIDR from tenant namespaces. The API server is typically at the host IP or 10.96.0.1, not in a pod namespace, so namespace-based deny may not cover it.
EOF
echo "Full results written to: $RESULTS_FILE"
if [[ $FAIL -gt 0 ]]; then
echo ""
echo -e "${RED}$FAIL test(s) failed — review results and fix network policies.${RST}"
exit 1
fi

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { registerCustomer } from "@/lib/zitadel"; import { registerCustomer } from "@/lib/zitadel";
import { rateLimit } from "@/lib/rate-limit";
import type { RegistrationInput } from "@/types"; import type { RegistrationInput } from "@/types";
import { z } from "zod"; import { z } from "zod";
@@ -11,7 +12,34 @@ const registrationSchema = z.object({
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(),
}); });
export async function POST(request: Request) { /** 3 registrations per IP per hour */
const RATE_LIMIT = 3;
const RATE_WINDOW_MS = 3_600_000; // 1 hour
export async function POST(request: NextRequest) {
// --- Rate limiting ---
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown";
const rl = rateLimit(`register:${ip}`, RATE_LIMIT, RATE_WINDOW_MS);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many registration attempts. Please try again later." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
"X-RateLimit-Limit": String(RATE_LIMIT),
"X-RateLimit-Remaining": "0",
},
},
);
}
// --- Validation ---
try { try {
const body = await request.json(); const body = await request.json();
const parsed = registrationSchema.safeParse(body); const parsed = registrationSchema.safeParse(body);
@@ -19,7 +47,7 @@ export async function POST(request: Request) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() }, { error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 } { status: 400 },
); );
} }
@@ -37,9 +65,16 @@ export async function POST(request: Request) {
{ {
orgId: result.orgId, orgId: result.orgId,
userId: result.userId, userId: result.userId,
message: "Registration successful. You will receive an invitation email to set your password.", message:
"Registration successful. You will receive an invitation email to set your password.",
},
{
status: 201,
headers: {
"X-RateLimit-Limit": String(RATE_LIMIT),
"X-RateLimit-Remaining": String(rl.remaining),
},
}, },
{ status: 201 }
); );
} catch (e: any) { } catch (e: any) {
console.error("Registration failed:", e); console.error("Registration failed:", e);
@@ -48,14 +83,14 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ error: zitadelMessage || "Registration failed. Please try again." }, { error: zitadelMessage || "Registration failed. Please try again." },
{ status: e.statusCode || 500 } { status: e.statusCode || 500 },
); );
} }
} }
/** /**
* ZITADEL errors come as: * ZITADEL errors come as:
* "ZITADEL POST /path: 400 {"code":3, "message":"..."}" * "ZITADEL POST /path: 400 {"code":3, "message":"..."}"
* Extract the human-readable "message" field. * Extract the human-readable "message" field.
*/ */
function extractZitadelMessage(errorMsg: string): string | null { function extractZitadelMessage(errorMsg: string): string | null {

71
src/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* In-memory sliding-window rate limiter.
*
* Suitable for single-node deployments (pilot scale).
* For multi-replica, replace with Redis-backed store.
*/
interface RateLimitEntry {
timestamps: number[];
}
const store = new Map<string, RateLimitEntry>();
// Cleanup stale entries every 10 minutes
if (typeof globalThis !== "undefined") {
// Use globalThis to survive HMR in dev — only one interval
const key = "__rateLimitCleanup";
if (!(globalThis as any)[key]) {
(globalThis as any)[key] = setInterval(() => {
const now = Date.now();
for (const [k, entry] of store) {
entry.timestamps = entry.timestamps.filter((t) => now - t < 3_600_000);
if (entry.timestamps.length === 0) store.delete(k);
}
}, 600_000);
}
}
export interface RateLimitResult {
allowed: boolean;
remaining: number;
/** Milliseconds until the oldest request in the window expires */
resetMs: number;
}
/**
* Check and record a rate-limited action.
*
* @param key - Unique key, e.g. `register:${ip}`
* @param limit - Max allowed actions in the window
* @param windowMs - Window size in milliseconds
*/
export function rateLimit(
key: string,
limit: number,
windowMs: number,
): RateLimitResult {
const now = Date.now();
const entry = store.get(key) ?? { timestamps: [] };
// Prune expired timestamps
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
if (entry.timestamps.length >= limit) {
const oldest = entry.timestamps[0];
return {
allowed: false,
remaining: 0,
resetMs: oldest + windowMs - now,
};
}
entry.timestamps.push(now);
store.set(key, entry);
return {
allowed: true,
remaining: limit - entry.timestamps.length,
resetMs: entry.timestamps[0] + windowMs - now,
};
}