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
14 changes: 12 additions & 2 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,20 @@ The connection-role migration preserves a sole eligible connection, prefers an u
- Signed-in decisions carry the actual actor's `userId` separately from the payer's `accountId`, including organization-paid requests.
- IP exemptions cover signup and inference; IPv6 covers the whole /64. An exempt signup IP bypasses both IP and JA4 counters. Signup has no user ID before account creation; no JA4 exemption or user fingerprint field is added.
- Admission and creation logs share the operation ID. They exclude raw email and distinguish completed creation, rejection, and Stripe or transaction failure.
- Cloud retains its local limits: 100 accounts per IP in 24 hours and 150 in 30 days. Existing authentication and identity guards remain unchanged.
- Bouncer owns signup admission. Cloud keeps `signup_ip` as evidence and retains all authentication and identity guards.
- Deploy Bouncer first with `enforcement.signup=false` and `enforcement.signupJa4=false`; all signup enforcement ships off. The existing `enforcement.signup` switch governs IP rejection, and `enforcement.signupJa4` independently governs JA4 rejection. The global `enforcement.enabled` switch gates both; no production switch is changed by deployment.
- Bouncer evaluates IP first and skips JA4 if IP rejects. It counts admissions, including failed creations: an IP admission remains counted if JA4 later rejects, just as it remains counted after a later Cloud transaction or Stripe failure. There are no refunds. Stable operation IDs deduplicate each bucket independently. Limits and window lengths are configurable in the Bouncer admin panel.
- Historical import counts completed accounts for IP only; never reconstruct historical signup JA4 from usage or payment fingerprints. JA4 coverage starts with live signup reports. Import IP history and catch up overlapping traffic before the cutover. Only Igor enables either signup enforcement switch. Verify enforced rejection before removing Cloud's local limiter.
- Historical import counts completed accounts for IP only; never reconstruct historical signup JA4 from usage or payment fingerprints. JA4 coverage starts with live signup reports.
- If the IP is valid, missing or invalid JA4 does not skip IP admission. JA4 enforcement requires a valid fingerprint.
- Deploy the first integration PR fully before this ownership cutover. Its existing Cloud limiter protects the historical import.
- From this checked-out cutover branch, run `pnpm --filter web script:run db import-signup-history` with the production script environment.
- The importer reads completed accounts from the last 30 days through a fixed time bound in 100-row pages. It changes no Cloud users.
- Each page requires a full Bouncer acknowledgement. Duplicate admissions are safe. Missing or empty IPs match the old limiter's exclusions.
- Invalid nonempty IPs, partial acknowledgement, HTTP errors, and saturation stop the import with a nonzero exit.
- Run the importer again after all first-PR instances deploy. For an incremental catch-up, append the previous run's `through` timestamp.
- After full acknowledgement and catch-up, only Igor enables IP signup enforcement and the global switch. Check shadow flags before independently enabling JA4 enforcement.
- Verify allowed and rejected signups with live configuration before merging and deploying the ownership cutover PR.
- After the cutover, Bouncer failure deliberately fails open. Revert the cutover PR to restore the old Cloud limiter.
- Signed-in decide requests carry the payer's `created_at`, `microdollars_used`, and `total_microdollars_acquired` (the organization's for an org request); `charge.attempted` carries the payer's `microdollars_used`.
- Usage reports have a 30-second transport budget and carry the request's charged `costMicrodollars`. Every inference endpoint reports its API kind, with the same request id as its decide.
- When the decide verdict says `spendWatch`, the usage event enters the durable `bouncer_usage_event_outbox` in the usage-write transaction, is delivered immediately after commit, and is retried by the cron drainer (eight attempts; delivered rows kept 1 day, failed rows 7 days).
Expand Down
124 changes: 0 additions & 124 deletions apps/web/src/lib/user/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1193,130 +1193,6 @@ describe('User', () => {
expect(result.user.is_admin).toBe(false);
});

it('rejects new signups after the per-IP burst threshold (100/24h)', async () => {
const signupIp = '203.0.113.50';
for (let i = 1; i <= 100; i++) {
await insertTestUser({ id: `ip-burst-${i}`, signup_ip: signupIp });
}

const result = await createOrUpdateUser(
{
google_user_email: 'limited@example.com',
google_user_name: 'Limited User',
google_user_image_url: 'https://example.com/avatar.png',
hosted_domain: null,
provider: 'google',
provider_account_id: 'google-limited',
},
undefined,
false,
new Headers({ 'x-forwarded-for': signupIp })
);

expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBe('SIGNUP-RATE-LIMITED');
expect(createStripeCustomer).toHaveBeenCalledTimes(1);
expect(deleteStripeCustomer).toHaveBeenCalledTimes(1);
expect(
await db.query.kilocode_users.findFirst({
where: eq(kilocode_users.google_user_email, 'limited@example.com'),
})
).toBeUndefined();
});

it('allows up to 99 signups in 24h from a single IP (burst below threshold)', async () => {
const signupIp = '203.0.113.51';
for (let i = 1; i <= 99; i++) {
await insertTestUser({ id: `ip-burst-ok-${i}`, signup_ip: signupIp });
}

const result = await createOrUpdateUser(
{
google_user_email: 'burst-ok@example.com',
google_user_name: 'Burst OK',
google_user_image_url: 'https://example.com/avatar.png',
hosted_domain: null,
provider: 'google',
provider_account_id: 'google-burst-ok',
},
undefined,
false,
new Headers({ 'x-forwarded-for': signupIp })
);

expect(result.success).toBe(true);
});

it('rejects new signups after the per-IP sustained threshold (150/30d)', async () => {
const signupIp = '203.0.113.52';
const now = Date.now();
// 99 signups yesterday — under the 24h burst threshold.
const yesterday = new Date(now - 2 * 60 * 60 * 1000).toISOString();
for (let i = 1; i <= 99; i++) {
await insertTestUser({
id: `ip-sustained-recent-${i}`,
signup_ip: signupIp,
created_at: yesterday,
});
}
// 51 more signups 10 days ago — outside the 24h window, inside 30d.
const tenDaysAgo = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString();
for (let i = 1; i <= 51; i++) {
await insertTestUser({
id: `ip-sustained-old-${i}`,
signup_ip: signupIp,
created_at: tenDaysAgo,
});
}

const result = await createOrUpdateUser(
{
google_user_email: 'sustained-limited@example.com',
google_user_name: 'Sustained Limited',
google_user_image_url: 'https://example.com/avatar.png',
hosted_domain: null,
provider: 'google',
provider_account_id: 'google-sustained-limited',
},
undefined,
false,
new Headers({ 'x-forwarded-for': signupIp })
);

expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBe('SIGNUP-RATE-LIMITED');
});

it('ignores signups older than 30 days when evaluating the sustained limit', async () => {
const signupIp = '203.0.113.53';
const longAgo = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString();
for (let i = 1; i <= 200; i++) {
await insertTestUser({
id: `ip-sustained-expired-${i}`,
signup_ip: signupIp,
created_at: longAgo,
});
}

const result = await createOrUpdateUser(
{
google_user_email: 'sustained-expired@example.com',
google_user_name: 'Sustained Expired',
google_user_image_url: 'https://example.com/avatar.png',
hosted_domain: null,
provider: 'google',
provider_account_id: 'google-sustained-expired',
},
undefined,
false,
new Headers({ 'x-forwarded-for': signupIp })
);

expect(result.success).toBe(true);
});

it('rejects new signups whose normalized_email is already in use', async () => {
await insertTestUser({
id: 'existing-normalized',
Expand Down
77 changes: 4 additions & 73 deletions apps/web/src/lib/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,7 @@ import {
quick_chat_threads,
quick_chat_messages,
} from '@kilocode/db/schema';
import {
eq,
and,
inArray,
isNotNull,
isNull,
sql,
or,
gte,
count,
ne,
notExists,
} from 'drizzle-orm';
import { eq, and, inArray, isNotNull, isNull, sql, or, ne, notExists } from 'drizzle-orm';
import { allow_fake_login, IS_DEVELOPMENT } from '@kilocode/web-shared/lib/constants';
import type { AuthErrorType } from '@kilocode/web-shared/lib/auth/constants';
import { shouldAutoProvisionPlatformAdmin } from '@/lib/admin/platform-admin';
Expand Down Expand Up @@ -218,64 +206,10 @@ if (process.env.NEXT_PUBLIC_POSTHOG_DEBUG) {
posthogClient.debug();
}

// Per-IP signup rate limit. Two overlapping windows so an IP can absorb a
// one-off spike (e.g. meetup where 100 people sign up from the same NAT in a
// single day) while still bounding sustained abuse. Passing requires both:
// - <= 100 signups in the last 24h (burst)
// - <= 150 signups in the last 30d (sustained, averages ~5/day)
// After a full burst day, only ~50 more signups are allowed over the next
// 29 days, and the burst day must roll out of the 30d window before the IP
// can spike again.
const SIGNUP_BURST_MAX = 100;
const SIGNUP_BURST_WINDOW_MS = 24 * 60 * 60 * 1000;
const SIGNUP_SUSTAINED_MAX = 150;
const SIGNUP_SUSTAINED_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;

function getSignupIp(requestHeaders?: Headers): string | null {
return requestHeaders?.get('x-forwarded-for')?.split(',')[0]?.trim() || null;
}

async function countSignupsFromIpSince(
signupIp: string,
sinceIso: string,
tx: DrizzleTransaction
): Promise<number> {
const [result] = await tx
.select({ count: count() })
.from(kilocode_users)
.where(and(eq(kilocode_users.signup_ip, signupIp), gte(kilocode_users.created_at, sinceIso)));
return result?.count ?? 0;
}

async function checkSignupIpRateLimit(
signupIp: string | null,
tx: DrizzleTransaction
): Promise<Result<null, AuthErrorType>> {
if (IS_DEVELOPMENT) return successResult(null);
if (!signupIp) return successResult(null);

const now = Date.now();
const burstWindowStart = new Date(now - SIGNUP_BURST_WINDOW_MS).toISOString();
const sustainedWindowStart = new Date(now - SIGNUP_SUSTAINED_WINDOW_MS).toISOString();

const burstCount = await countSignupsFromIpSince(signupIp, burstWindowStart, tx);
const sustainedCount = await countSignupsFromIpSince(signupIp, sustainedWindowStart, tx);

if (burstCount < SIGNUP_BURST_MAX && sustainedCount < SIGNUP_SUSTAINED_MAX) {
return successResult(null);
}

console.warn('[auth] Signup rejected due to per-IP rate limit', {
ip_address: signupIp,
existing_accounts_24h: burstCount,
existing_accounts_30d: sustainedCount,
max_signups_24h: SIGNUP_BURST_MAX,
max_signups_30d: SIGNUP_SUSTAINED_MAX,
});

return failureResult('SIGNUP-RATE-LIMITED');
}

async function checkNormalizedEmailUnique(
normalizedEmail: string,
tx: DrizzleTransaction
Expand Down Expand Up @@ -681,8 +615,8 @@ export async function createOrUpdateUser(

// New user creation path — Stripe customer is created before the DB
// transaction because stripe_customer_id is NOT NULL. If the transaction
// fails (rate limit, constraint violation, etc.) we clean up the Stripe
// customer to prevent orphans.
// fails (uniqueness rejection, constraint violation, etc.) we clean up the
// Stripe customer to prevent orphans.
const stripeCustomer = await createStripeCustomer({
email: args.google_user_email,
name: args.google_user_name,
Expand Down Expand Up @@ -722,9 +656,6 @@ export async function createOrUpdateUser(
let caughtError: unknown;
try {
txResult = await db.transaction(async tx => {
const signupRateLimitResult = await checkSignupIpRateLimit(signupIp, tx);
if (!signupRateLimitResult.success) return signupRateLimitResult;

const dedupResult = await checkNormalizedEmailUnique(newUser.normalized_email, tx);
if (!dedupResult.success) return dedupResult;

Expand Down Expand Up @@ -758,7 +689,7 @@ export async function createOrUpdateUser(
}

// Clean up the Stripe customer when signup didn't succeed (thrown error
// or returned failure like rate-limit rejection).
// or returned failure like a normalized-email uniqueness rejection).
if (!txResult.success) {
console.info('[auth] Signup creation outcome', {
operationId,
Expand Down
Loading
Loading