Merge branch 'dev' into feat/redirect-tables

This commit is contained in:
Fred KISSIE
2026-09-22 22:36:53 +02:00
66 changed files with 1715 additions and 371 deletions
@@ -386,6 +386,38 @@ function runSpecialCharacterTests() {
console.log("All special character tests passed!");
}
function runEncodedPatternTests() {
console.log("\nRunning percent-encoded pattern tests...");
// isValidUrlGlobPattern accepts percent-encoded sequences and rejects
// raw spaces / non-ASCII, so `%20` and `%C3%A9` are the only way to write
// a PATH rule for such a path. Badger sends the request path already
// decoded (Go's req.URL.Path), and isPathAllowed decodes it again, so the
// rule pattern must be decoded the same way or it can never match.
assertEquals(
isPathAllowed("/my%20docs/*", "/my docs/report.pdf"),
true,
"Percent-encoded space in pattern should match decoded request path"
);
assertEquals(
isPathAllowed("/my%20docs/*", "/my%20docs/report.pdf"),
true,
"Percent-encoded space in pattern should match raw-encoded request path"
);
assertEquals(
isPathAllowed("/caf%C3%A9", "/café"),
true,
"Percent-encoded UTF-8 in pattern should match decoded request path"
);
assertEquals(
isPathAllowed("/my%20docs/*", "/my-docs/report.pdf"),
false,
"Decoded pattern must still reject a different path"
);
console.log("All percent-encoded pattern tests passed!");
}
function runRegionTests() {
console.log("\nRunning isIpInRegion tests...");
@@ -446,6 +478,7 @@ function runRegionTests() {
try {
runTests();
runSpecialCharacterTests();
runEncodedPatternTests();
runRegionTests();
console.log("\n✅ All tests passed!");
} catch (error) {
+19 -3
View File
@@ -41,9 +41,10 @@ import {
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { isPathAllowed } from "@server/lib/pathMatch";
import { isPathAllowed, type isPathAllowed } from "@server/lib/pathMatch";
import { matchesPath } from "@server/lib/traefik/rule";
import { rewriteRequestPath } from "@server/lib/traefik/middleware";
import { parseHttpMethodList } from "@server/lib/validators";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -171,6 +172,7 @@ export async function verifyResourceSession(
path,
headers,
query,
method,
badgerVersion
} = parsedBody.data;
@@ -323,7 +325,8 @@ export async function verifyResourceSession(
clientIp,
path,
ipCC,
ipAsn
ipAsn,
method
);
if (action == "ACCEPT") {
@@ -1524,7 +1527,8 @@ async function checkRules(
clientIp: string | undefined,
path: string | undefined,
ipCC?: string,
ipAsn?: number
ipAsn?: number,
method?: string
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`;
@@ -1599,12 +1603,24 @@ async function checkRules(
(await isIpInRegion(ipCC, rule.value))
) {
return rule.action as any;
} else if (
method &&
rule.match == "METHOD" &&
isMethodAllowed(rule.value, method)
) {
return rule.action as any;
}
}
return;
}
// rule.value holds a comma-separated list of HTTP methods, e.g. "POST,PUT".
function isMethodAllowed(ruleValue: string, method: string): boolean {
const requestMethod = method.toUpperCase();
return parseHttpMethodList(ruleValue).includes(requestMethod);
}
export { isPathAllowed };
async function isIpInGeoIP(
+1 -1
View File
@@ -31,7 +31,7 @@ export async function addPeer(
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) {
throw new Error(`Site found for site ${siteId}`);
throw new Error(`Newt not found for site ${siteId}`);
}
newtId = newt.newtId;
}
+2 -1
View File
@@ -25,7 +25,7 @@ import { fromError } from "zod-validation-error";
import { defaultRoleAllowedActions } from "../role";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
import { createCustomer } from "#dynamic/lib/billing";
import { createCustomer, linkEmailOrg } from "#dynamic/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing";
import { build } from "@server/build";
@@ -425,6 +425,7 @@ export async function createOrg(
customerId
); // Only 1 because we are creating the org
}
await linkEmailOrg(orgId, req.user?.email);
}
if (numOrgs) {
+9 -5
View File
@@ -53,9 +53,10 @@ async function queryInlinePolicy(resourcePolicyId: number) {
export type GetResourceResponse = Omit<
NonNullable<Awaited<ReturnType<typeof query>>>,
"headers"
"requestHeaders" | "responseHeaders"
> & {
headers: { name: string; value: string }[] | null;
requestHeaders: { name: string; value: string }[] | null;
responseHeaders: { name: string; value: string }[] | null;
};
registry.registerPath({
@@ -186,9 +187,12 @@ export async function getResource(
return response<GetResourceResponse>(res, {
data: {
...returnData,
headers: returnData.headers
? JSON.parse(returnData.headers)
: returnData.headers
requestHeaders: returnData.requestHeaders
? JSON.parse(returnData.requestHeaders)
: returnData.requestHeaders,
responseHeaders: returnData.responseHeaders
? JSON.parse(returnData.responseHeaders)
: returnData.responseHeaders
},
success: true,
error: false,
+61 -32
View File
@@ -100,6 +100,14 @@ const updateHttpResourceBodySchema = z
"When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource."
),
headers: z
.array(z.strictObject({ name: z.string(), value: z.string() }))
.nullable()
.optional(), // deprecated alias for requestHeaders
requestHeaders: z
.array(z.strictObject({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
responseHeaders: z
.array(z.strictObject({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
@@ -163,12 +171,13 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
if (data.headers) {
// HTTP header names must be valid token characters (RFC 7230)
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
return data.headers.every((h) => validHeaderName.test(h.name));
}
return true;
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every((h) => validHeaderName.test(h.name));
},
{
error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)."
@@ -176,14 +185,13 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
if (data.headers) {
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
const validHeaderValue = /^[\t\x20-\x7E]*$/;
return data.headers.every((h) =>
validHeaderValue.test(h.value)
);
}
return true;
const validHeaderValue = /^[\t\x20-\x7E]*$/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every((h) => validHeaderValue.test(h.value));
},
{
error: "Header values may only contain printable ASCII characters and horizontal whitespace."
@@ -191,16 +199,17 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
if (data.headers) {
// Reject Traefik template syntax {{word}} in names or values
const templatePattern = /\{\{[^}]+\}\}/;
return data.headers.every(
(h) =>
!templatePattern.test(h.name) &&
!templatePattern.test(h.value)
);
}
return true;
const templatePattern = /\{\{[^}]+\}\}/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every(
(h) =>
!templatePattern.test(h.name) &&
!templatePattern.test(h.value)
);
},
{
error: "Header names and values must not contain template expressions such as {{value}}."
@@ -500,7 +509,8 @@ async function updateHttpResource(
}
// catch when the resource policy changes or gets cleared
if (resource.resourcePolicyId != updateData.resourcePolicyId) {
if (updateData.resourcePolicyId !== undefined &&
resource.resourcePolicyId !== updateData.resourcePolicyId) {
await clearResourceSpecificSettings(
resource.resourceId,
resource.orgId,
@@ -695,13 +705,32 @@ async function updateHttpResource(
await createCertificate(domainId, fullDomain, db);
}
let headers = undefined;
if (updateData.headers) {
headers = JSON.stringify(updateData.headers);
} else if (updateData.headers === null) {
headers = null;
let requestHeaders = undefined;
const mergedRequestHeaders = [
...(updateData.headers ?? []),
...(updateData.requestHeaders ?? [])
];
if (
updateData.headers !== undefined ||
updateData.requestHeaders !== undefined
) {
requestHeaders =
mergedRequestHeaders.length > 0
? JSON.stringify(mergedRequestHeaders)
: null;
}
let responseHeaders = undefined;
if (updateData.responseHeaders) {
responseHeaders = JSON.stringify(updateData.responseHeaders);
} else if (updateData.responseHeaders === null) {
responseHeaders = null;
}
updateData.headers = undefined;
updateData.requestHeaders = undefined;
updateData.responseHeaders = undefined;
if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined;
@@ -754,7 +783,7 @@ async function updateHttpResource(
const updatedResource = await db
.update(resources)
.set({ ...resourceOnlyData, headers })
.set({ ...resourceOnlyData, requestHeaders, responseHeaders })
.where(eq(resources.resourceId, resource.resourceId))
.returning();
@@ -778,7 +807,7 @@ async function updateHttpResource(
const updatedResource = await db
.update(resources)
.set({ ...updateData, headers })
.set({ ...updateData, requestHeaders, responseHeaders })
.where(eq(resources.resourceId, resource.resourceId))
.returning();
+1 -1
View File
@@ -263,7 +263,7 @@ export async function createSite(
const { value: newClientAddress, release } =
await getNextAvailableClientSubnet(orgId);
releaseSubnetLock = release;
updatedAddress = newClientAddress.split("/")[0];
updatedAddress = `${newClientAddress.split("/")[0]}/${org.subnet ? org.subnet.split("/")[1] : "32"}`;
}
let newSite: Site | undefined;
+1 -1
View File
@@ -113,7 +113,7 @@ export async function updateSite(
.where(
and(
eq(sites.niceId, updateData.niceId),
eq(sites.orgId, sites.orgId),
eq(sites.orgId, existingSite.orgId),
ne(sites.siteId, siteId)
)
)
@@ -0,0 +1,58 @@
import { assertEquals } from "@test/assert";
import { getSiteResourceParamsSchema } from "./getSiteResource";
function testSiteResourceIdOnlyParams() {
const result = getSiteResourceParamsSchema.safeParse({
siteResourceId: "42"
});
assertEquals(
result.success,
true,
"siteResourceId-only integration routes should pass validation"
);
if (result.success) {
assertEquals(
result.data.siteResourceId,
42,
"siteResourceId should be parsed as a number"
);
assertEquals(
result.data.orgId,
undefined,
"orgId should remain optional"
);
}
}
function testOrgScopedParamsRemainSupported() {
const result = getSiteResourceParamsSchema.safeParse({
siteResourceId: "42",
orgId: "org-id"
});
assertEquals(
result.success,
true,
"org-scoped routes should continue to pass validation"
);
}
function testInvalidSiteResourceId() {
const result = getSiteResourceParamsSchema.safeParse({
siteResourceId: "not-a-number"
});
assertEquals(
result.success,
false,
"non-numeric siteResourceIds should fail validation"
);
}
testSiteResourceIdOnlyParams();
testOrgScopedParamsRemainSupported();
testInvalidSiteResourceId();
console.log("All getSiteResource parameter validation tests passed.");
@@ -10,7 +10,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
const getSiteResourceParamsSchema = z.strictObject({
export const getSiteResourceParamsSchema = z.strictObject({
siteResourceId: z
.string()
.optional()
@@ -22,15 +22,17 @@ const getSiteResourceParamsSchema = z.strictObject({
});
async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
if (siteResourceId && orgId) {
if (siteResourceId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
orgId
? and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.orgId, orgId)
)
: eq(siteResources.siteResourceId, siteResourceId)
)
.limit(1);
return siteResource;