Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 3 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
DATABASE_URL: postgresql://user:password@example.com/neondb?sslmode=require
NEON_AUTH_BASE_URL: https://example.neon.tech/neondb/auth
NEXT_PUBLIC_NEON_AUTH_BASE_URL: https://example.neon.tech/neondb/auth
NEON_AUTH_COOKIE_SECRET: test-test-test-test-test-test-test-test
DATABASE_URL: postgresql://user:password@example.com/clawproxy
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -30,7 +28,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn

- name: Enable Corepack (Yarn from packageManager)
run: corepack enable
Expand Down Expand Up @@ -63,9 +60,6 @@ jobs:
context: .
push: false
build-args: |
NEXT_PUBLIC_NEON_AUTH_BASE_URL=https://example.neon.tech/neondb/auth
DATABASE_URL=postgresql://user:password@example.com/neondb?sslmode=require
NEON_AUTH_BASE_URL=https://example.neon.tech/neondb/auth
NEON_AUTH_COOKIE_SECRET=test-test-test-test-test-test-test-test
DATABASE_URL=postgresql://user:password@example.com/clawproxy
cache-from: type=gha
cache-to: type=gha,mode=max
24 changes: 7 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,13 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build-time env (Next inlines NEXT_PUBLIC_*; Neon Auth and DB modules load during `next build`).
# NEON_AUTH_COOKIE_SECRET gets a placeholder because @neondatabase/auth validates
# the 32-char minimum at import time during page-data collection; the real secret
# is supplied at runtime via the container environment.
ARG NEXT_PUBLIC_NEON_AUTH_BASE_URL
# Build-time env. The app reads DATABASE_URL at runtime, but Next may evaluate server
# modules during `next build`, so CI/Coolify builds should provide the same Postgres URL.
ARG DATABASE_URL
ARG NEON_AUTH_BASE_URL
ARG NEON_AUTH_COOKIE_SECRET=build-time-placeholder-not-a-real-secret
ENV DATABASE_URL=$DATABASE_URL

ENV NEXT_PUBLIC_NEON_AUTH_BASE_URL=$NEXT_PUBLIC_NEON_AUTH_BASE_URL \
DATABASE_URL=$DATABASE_URL \
NEON_AUTH_BASE_URL=$NEON_AUTH_BASE_URL \
NEON_AUTH_COOKIE_SECRET=$NEON_AUTH_COOKIE_SECRET

RUN test -n "$NEXT_PUBLIC_NEON_AUTH_BASE_URL" && test -n "$DATABASE_URL" && \
test -n "$NEON_AUTH_BASE_URL" || \
(echo "ERROR: Docker build requires --build-arg NEXT_PUBLIC_NEON_AUTH_BASE_URL, DATABASE_URL, NEON_AUTH_BASE_URL" >&2; exit 1)
RUN test -n "$DATABASE_URL" || \
(echo "ERROR: Docker build requires --build-arg DATABASE_URL" >&2; exit 1)

ENV NEXT_TELEMETRY_DISABLED=1

Expand All @@ -44,13 +34,13 @@ WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# DATABASE_URL must be supplied at container runtime (Neon dashboard connection string).
# The build-time DATABASE_URL is only for `next build`; the app reads the env var when the server runs.
# DATABASE_URL must be supplied at container runtime (Coolify Postgres connection string).

RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/db/migrations ./db/migrations

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
Expand Down
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

clawproxy is a **public webhook relay for private OpenClaw nodes**. The core engine works:

- ✅ **Auth:** Neon Auth for human admins, hashed bearer tokens for machine nodes
- ✅ **Auth:** App-owned email/password auth in Postgres, hashed bearer tokens for machine nodes
- ✅ **Database:** Postgres schema with nodes, routes, events (Drizzle ORM)
- ✅ **Ingress:** `POST /api/ingress/:routeSlug` accepts and persists webhooks
- ✅ **Delivery:** Pull-based polling with lease/ack, retry on expiry, TTL enforcement
Expand Down
21 changes: 7 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,13 @@ Nodes connect to `wss://<your-clawproxy-host>/api/nodes/ws` and authenticate imm
### Prerequisites

- Node.js 22+
- A [Neon](https://neon.tech) Postgres database
- A [Neon Auth](https://neon.tech/docs/guides/neon-auth) project
- A Postgres database (Coolify Postgres is the recommended production default)

### Environment variables

| Variable | Required | Description |
|---|---|---|
| `DATABASE_URL` | Yes | Neon Postgres connection string |
| `NEON_AUTH_BASE_URL` | Yes | Neon Auth base URL (server-side) |
| `NEON_AUTH_COOKIE_SECRET` | Yes | Secret used to sign session cookies (min 32 chars) |
| `NEXT_PUBLIC_NEON_AUTH_BASE_URL` | Yes | Neon Auth base URL (embedded in the client bundle at build time) |
| `DATABASE_URL` | Yes | Postgres connection string (Coolify Postgres recommended) |
| `ENCRYPTION_KEY` | Yes | 64-character hex string (32 bytes) used for AES-256-GCM encryption of sensitive fields at rest. Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |

Copy `.env.example` (if present) to `.env.local` and fill in the values before running locally.
Expand Down Expand Up @@ -138,11 +134,11 @@ The project ships with a multi-stage `Dockerfile` that produces a minimal produc

**Build the image**

`NEXT_PUBLIC_NEON_AUTH_BASE_URL` is baked into the client bundle at build time, so it must be passed as a build argument:
Pass the database URL as a build argument because Next may evaluate server modules during `next build`:

```bash
docker build \
--build-arg NEXT_PUBLIC_NEON_AUTH_BASE_URL=https://<your-neon-auth-url> \
--build-arg DATABASE_URL="postgresql://..." \
-t clawproxy .
```

Expand All @@ -153,8 +149,7 @@ Pass runtime secrets as environment variables:
```bash
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://..." \
-e NEON_AUTH_BASE_URL="https://..." \
-e NEON_AUTH_COOKIE_SECRET="..." \
-e ENCRYPTION_KEY="$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")" \
clawproxy
```

Expand All @@ -168,10 +163,8 @@ Set the following environment variables in your platform's dashboard before depl

| Variable | Notes |
|---|---|
| `DATABASE_URL` | Neon Postgres connection string |
| `NEON_AUTH_BASE_URL` | Neon Auth base URL |
| `NEON_AUTH_COOKIE_SECRET` | Session cookie signing secret |
| `NEXT_PUBLIC_NEON_AUTH_BASE_URL` | Must be set **before** the build runs so it is inlined into the client bundle |
| `DATABASE_URL` | Postgres connection string (Coolify Postgres recommended) |
| `ENCRYPTION_KEY` | 64-character hex encryption key for sensitive payload fields |

Deploy with the Nixpacks CLI:

Expand Down
8 changes: 6 additions & 2 deletions app/api/auth/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { auth } from '@/app/lib/auth/server';
export function GET() {
return Response.json({ ok: false, error: 'Unknown auth endpoint' }, { status: 404 });
}

export const { GET, POST } = auth.handler();
export function POST() {
return Response.json({ ok: false, error: 'Unknown auth endpoint' }, { status: 404 });
}
47 changes: 47 additions & 0 deletions app/api/auth/_shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';

import { SESSION_COOKIE_NAME } from '@/app/lib/auth/constants';

export type JsonBody = Record<string, unknown>;

export async function readJson(request: Request): Promise<JsonBody | null> {
try {
const body = await request.json();
return typeof body === 'object' && body !== null ? (body as JsonBody) : null;
} catch {
return null;
}
}

export function stringField(body: JsonBody | null, key: string) {
const value = body?.[key];
return typeof value === 'string' ? value : '';
}

export function okWithSession(data: unknown, token: string, expiresAt: Date) {
const response = NextResponse.json({ ok: true, data });
response.cookies.set({
name: SESSION_COOKIE_NAME,
value: token,
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
expires: expiresAt,
});
return response;
}

export function clearSessionResponse() {
const response = NextResponse.json({ ok: true });
response.cookies.set({
name: SESSION_COOKIE_NAME,
value: '',
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return response;
}
8 changes: 8 additions & 0 deletions app/api/auth/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { requestPasswordReset } from '@/app/lib/auth/server';
import { readJson, stringField } from '../_shared';

export async function POST(request: Request) {
const body = await readJson(request);
await requestPasswordReset(stringField(body, 'email'));
return Response.json({ ok: true });
}
12 changes: 2 additions & 10 deletions app/api/auth/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
import { auth } from '@/app/lib/auth/server';

const handler = auth.handler();

export async function GET(request: Request) {
return handler.GET(request, { params: Promise.resolve({ path: [] }) });
}

export async function POST(request: Request) {
return handler.POST(request, { params: Promise.resolve({ path: [] }) });
export function GET() {
return Response.json({ ok: true, service: 'clawproxy-auth' });
}
53 changes: 53 additions & 0 deletions app/api/auth/sign-in/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextResponse } from 'next/server';

import {
checkAuthRateLimit,
clearAuthRateLimit,
recordAuthRateLimitAttempt,
} from '@/app/lib/auth/rate-limit';
import { signInWithPassword } from '@/app/lib/auth/server';
import { okWithSession, readJson, stringField } from '../_shared';

const SIGN_IN_WINDOW_MS = 15 * 60 * 1000;
const SIGN_IN_MAX_FAILED_ATTEMPTS = 5;
const SIGN_IN_KIND = 'sign-in';

function signInKey(email: string) {
return `${SIGN_IN_KIND}:${email.trim().toLowerCase()}`;
}

function tooManyAttemptsResponse(retryAfterSeconds: number) {
return NextResponse.json(
{ ok: false, error: 'Too many sign-in attempts' },
{
status: 429,
headers: { 'retry-after': String(retryAfterSeconds) },
}
);
}

export async function POST(request: Request) {
const body = await readJson(request);
const email = stringField(body, 'email');
const key = signInKey(email);

const rateLimit = await checkAuthRateLimit({
key,
kind: SIGN_IN_KIND,
maxAttempts: SIGN_IN_MAX_FAILED_ATTEMPTS,
windowMs: SIGN_IN_WINDOW_MS,
});
if (rateLimit.limited) {
return tooManyAttemptsResponse(rateLimit.retryAfterSeconds);
}

const result = await signInWithPassword(email, stringField(body, 'password'));

if (!result.ok) {
await recordAuthRateLimitAttempt({ key, kind: SIGN_IN_KIND, windowMs: SIGN_IN_WINDOW_MS });
return NextResponse.json({ ok: false, error: result.error }, { status: result.status ?? 401 });
}

await clearAuthRateLimit(key);
return okWithSession({ user: result.user }, result.token, result.expiresAt);
}
Comment on lines +29 to +53

@strix-security strix-security Bot Sep 23, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sign-in/sign-up throttling bypassable via spoofed X-Forwarded-For and per-process state

Resolved — no longer flagged as of 29948b0.

15 changes: 15 additions & 0 deletions app/api/auth/sign-out/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { SESSION_COOKIE_NAME } from '@/app/lib/auth/constants';
import { signOutSession } from '@/app/lib/auth/server';
import { clearSessionResponse } from '../_shared';

export async function POST(request: Request) {
const token = request.headers
.get('cookie')
?.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`${SESSION_COOKIE_NAME}=`))
?.slice(SESSION_COOKIE_NAME.length + 1);

await signOutSession(token);
return clearSessionResponse();
}
54 changes: 54 additions & 0 deletions app/api/auth/sign-up/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';

import {
checkAuthRateLimit,
recordAuthRateLimitAttempt,
} from '@/app/lib/auth/rate-limit';
import { signUpWithPassword } from '@/app/lib/auth/server';
import { okWithSession, readJson, stringField } from '../_shared';

const SIGN_UP_WINDOW_MS = 15 * 60 * 1000;
const SIGN_UP_MAX_ATTEMPTS = 5;
const SIGN_UP_KIND = 'sign-up';
const SIGN_UP_GLOBAL_KEY = `${SIGN_UP_KIND}:global`;

function tooManyAttemptsResponse(retryAfterSeconds: number) {
return NextResponse.json(
{ ok: false, error: 'Too many sign-up attempts' },
{
status: 429,
headers: { 'retry-after': String(retryAfterSeconds) },
}
);
}

export async function POST(request: Request) {
const body = await readJson(request);
const rateLimit = await checkAuthRateLimit({
key: SIGN_UP_GLOBAL_KEY,
kind: SIGN_UP_KIND,
maxAttempts: SIGN_UP_MAX_ATTEMPTS,
windowMs: SIGN_UP_WINDOW_MS,
});
if (rateLimit.limited) {
return tooManyAttemptsResponse(rateLimit.retryAfterSeconds);
}

await recordAuthRateLimitAttempt({
key: SIGN_UP_GLOBAL_KEY,
kind: SIGN_UP_KIND,
windowMs: SIGN_UP_WINDOW_MS,
});

const result = await signUpWithPassword({
email: stringField(body, 'email'),
password: stringField(body, 'password'),
name: stringField(body, 'name'),
});

if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: result.status ?? 400 });
}

return okWithSession({ user: result.user }, result.token, result.expiresAt);
}
4 changes: 2 additions & 2 deletions app/auth/forgot-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import Link from 'next/link';
import { FormEvent, useState } from 'react';

import { createNeonClientAuth } from '@/app/lib/auth/client';
import { createClientAuth } from '@/app/lib/auth/client';
import { useRedirect127ToLocalhost } from '@/app/lib/auth/dev-origin';
import { AdminShell } from '@/components/app/admin-shell';
import { Alert, AlertDescription } from '@/components/ui/alert';
Expand All @@ -27,7 +27,7 @@ export default function ForgotPasswordPage() {
setSuccess(null);

try {
const auth = await createNeonClientAuth();
const auth = await createClientAuth();
const result = await auth.requestPasswordReset({
email,
redirectTo: '/auth/sign-in',
Expand Down
4 changes: 2 additions & 2 deletions app/auth/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { FormEvent, Suspense, useState } from 'react';
import posthog from 'posthog-js';

import { createNeonClientAuth } from '@/app/lib/auth/client';
import { createClientAuth } from '@/app/lib/auth/client';
import { useRedirect127ToLocalhost } from '@/app/lib/auth/dev-origin';
import { resolvePostSignInRedirect } from '@/app/lib/auth/post-sign-in-redirect';
import { AdminShell } from '@/components/app/admin-shell';
Expand Down Expand Up @@ -39,7 +39,7 @@ function SignInForm() {
setError(null);

try {
const auth = await createNeonClientAuth();
const auth = await createClientAuth();
const result = await auth.signIn.email({
email,
password,
Expand Down
Loading
Loading