mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 15:09:07 +01:00
Merge branch 'dev' into feat/redirect-tables
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user