feat(security): opt-in local CrowdSec LAPI bouncer on the Docker engine
This commit is contained in:
1 parent
3e1a3f92c8
commit
84d53139a9
15 files changed
+1002
-2
No files matched your search
+25
@@ -197,6 +197,23 @@ const schema = z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://api.crowdsec.net/v3"),
|
||||
// Local CrowdSec engine shipped as an opt-in Docker stack in
|
||||
// deployment/crowdsec. When enabled, the anti-DDoS gate asks the local
|
||||
// LAPI (bouncer) for each client IP before its own buckets and blocks
|
||||
// ban/captcha decisions immediately. The key lives in env only.
|
||||
CROWDSEC_LOCAL_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value === "true" || value === "1"),
|
||||
CROWDSEC_LAPI_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value?.trim() ? value.trim() : "http://127.0.0.1:18080",
|
||||
)
|
||||
.pipe(z.string().url()),
|
||||
CROWDSEC_LAPI_API_KEY: z.string().optional(),
|
||||
CROWDSEC_LAPI_TIMEOUT_MS: z.coerce.number().int().positive().default(500),
|
||||
// Watcher credentials for signal push. When omitted, a stable pair is
|
||||
// generated once and persisted in Redis (48-char alnum machine id,
|
||||
// per the CAPI schema).
|
||||
@@ -232,6 +249,14 @@ const schema = z
|
||||
path: ["PAYPAL_CLIENT_ID"],
|
||||
});
|
||||
}
|
||||
if (data.CROWDSEC_LOCAL_ENABLED && !data.CROWDSEC_LAPI_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"CROWDSEC_LAPI_API_KEY is required when CROWDSEC_LOCAL_ENABLED=true",
|
||||
path: ["CROWDSEC_LAPI_API_KEY"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type Env = z.infer<typeof schema>;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { invalidateAntiddosConfig } from "@/lib/antiddos-config";
|
||||
import { resetCrowdsecCache } from "@/lib/crowdsec-api";
|
||||
import { resetCrowdsecLocalCache } from "@/lib/crowdsec-local";
|
||||
import { enforceDdosRateLimit } from "@/lib/ddos-guard";
|
||||
|
||||
// The gate's block escalation (and thus the CrowdSec hook) only runs when
|
||||
@@ -123,6 +124,7 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
|
||||
state.z.clear();
|
||||
state.sendAlert.mockReset();
|
||||
resetCrowdsecCache();
|
||||
resetCrowdsecLocalCache();
|
||||
invalidateAntiddosConfig();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
@@ -141,6 +143,7 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
|
||||
vi.unstubAllEnvs();
|
||||
state.map.clear();
|
||||
resetCrowdsecCache();
|
||||
resetCrowdsecLocalCache();
|
||||
invalidateAntiddosConfig();
|
||||
});
|
||||
|
||||
@@ -244,4 +247,29 @@ describe("anti-DDoS automatic CrowdSec blocks", () => {
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks immediately on a local LAPI ban decision (app-layer bouncer)", async () => {
|
||||
vi.stubEnv("CROWDSEC_LOCAL_ENABLED", "true");
|
||||
vi.stubEnv("CROWDSEC_LAPI_URL", "http://127.0.0.1:18080");
|
||||
vi.stubEnv("CROWDSEC_LAPI_API_KEY", "local-bouncer-key");
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
if (String(input).startsWith("http://127.0.0.1:18080/")) {
|
||||
return jsonResponse([
|
||||
{
|
||||
origin: "crowdsec",
|
||||
type: "ban",
|
||||
scope: "ip",
|
||||
value: "198.51.100.88",
|
||||
duration: "1h",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return jsonResponse({ message: "unexpected upstream" }, 500);
|
||||
});
|
||||
|
||||
const ip = "198.51.100.88";
|
||||
const blocks = await pump(proxiedRequest(ip), 1);
|
||||
expect(blocks).toBe(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { resolveClientIp } from "@/lib/client-ip";
|
||||
import { isCloudflareProxied } from "@/lib/cloudflare";
|
||||
import { maybeAutoBlockCloudflare } from "@/lib/cloudflare-api";
|
||||
import { maybeAutoBlockCrowdsec } from "@/lib/crowdsec-api";
|
||||
import { checkCrowdsecLocalBlock } from "@/lib/crowdsec-local";
|
||||
import { reportCrowdsecSignal } from "@/lib/crowdsec-report";
|
||||
import {
|
||||
bumpCrowdsecBreakdownStat,
|
||||
@@ -84,6 +85,14 @@ export async function enforceDdosRateLimit(
|
||||
}
|
||||
}
|
||||
|
||||
const localBlock = await checkCrowdsecLocalBlock(ip);
|
||||
if (localBlock.blocked) {
|
||||
return {
|
||||
outcome: "block",
|
||||
retryAfterSeconds: Math.max(localBlock.retryAfterSeconds, 1),
|
||||
};
|
||||
}
|
||||
|
||||
const global = await rateLimit(
|
||||
"antiddos:global:all",
|
||||
config.global.limit,
|
||||
|
||||
Reference in new issue
Block a user