mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-06 07:38:46 +00:00
Compare commits
11 Commits
crowdin_de
...
1.19.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa1180d10 | ||
|
|
769d36e289 | ||
|
|
a7a41b820e | ||
|
|
8b50f1fb65 | ||
|
|
2d78a4b628 | ||
|
|
527d4cc777 | ||
|
|
01361884eb | ||
|
|
6c4cbcab5d | ||
|
|
aac25f0a53 | ||
|
|
f617f93a94 | ||
|
|
51629247a5 |
@@ -38,7 +38,5 @@ flags:
|
|||||||
disable_user_create_org: false
|
disable_user_create_org: false
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
|
||||||
{{if .IsPostgreSQL}}
|
{{if .IsPostgreSQL}}postgres:
|
||||||
postgres:
|
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
|
||||||
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
@@ -7,23 +7,17 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 1g
|
memory: 2g
|
||||||
reservations:
|
reservations:
|
||||||
memory: 256m
|
memory: 512m
|
||||||
{{if or .IsPostgreSQL .IsRedis}}
|
{{if or .IsPostgreSQL .IsRedis}}depends_on:
|
||||||
depends_on:
|
{{if .IsPostgreSQL}}postgres:
|
||||||
{{if .IsPostgreSQL}}
|
condition: service_healthy{{end}}
|
||||||
postgres:
|
{{if .IsRedis}}redis:
|
||||||
condition: service_healthy
|
condition: service_healthy{{end}}
|
||||||
{{end}}
|
|
||||||
{{if .IsRedis}}
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
{{end}}
|
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- backend
|
- backend{{end}}
|
||||||
{{end}}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -31,8 +25,8 @@ services:
|
|||||||
interval: "10s"
|
interval: "10s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
retries: 15
|
retries: 15
|
||||||
{{if .InstallGerbil}}
|
|
||||||
gerbil:
|
{{if .InstallGerbil}}gerbil:
|
||||||
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -53,8 +47,8 @@ services:
|
|||||||
- 21820:21820/udp
|
- 21820:21820/udp
|
||||||
- 443:443
|
- 443:443
|
||||||
- 443:443/udp # For http3 QUIC if desired
|
- 443:443/udp # For http3 QUIC if desired
|
||||||
- 80:80
|
- 80:80{{end}}
|
||||||
{{end}}
|
|
||||||
traefik:
|
traefik:
|
||||||
image: docker.io/traefik:v3.6
|
image: docker.io/traefik:v3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
@@ -62,8 +56,7 @@ services:
|
|||||||
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80
|
- 80:80{{end}}
|
||||||
{{end}}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
pangolin:
|
pangolin:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -74,8 +67,7 @@ services:
|
|||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
{{if .IsPostgreSQL}}
|
{{if .IsPostgreSQL}}postgres:
|
||||||
postgres:
|
|
||||||
image: postgres:18
|
image: postgres:18
|
||||||
container_name: postgres
|
container_name: postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -91,11 +83,9 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend{{end}}
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .IsRedis}}
|
{{if .IsRedis}}redis:
|
||||||
redis:
|
|
||||||
image: redis:8-trixie
|
image: redis:8-trixie
|
||||||
container_name: redis
|
container_name: redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -113,17 +103,14 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend{{end}}
|
||||||
{{end}}
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin_frontend
|
name: pangolin_frontend
|
||||||
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
||||||
{{if or .IsPostgreSQL .IsRedis}}
|
{{if or .IsPostgreSQL .IsRedis}} backend:
|
||||||
backend:
|
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin_backend
|
name: pangolin_backend
|
||||||
internal: true
|
internal: true{{end}}
|
||||||
{{end}}
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
{{if .IsRedis}}
|
{{if .IsRedis}}redis:
|
||||||
redis:
|
|
||||||
host: "redis"
|
host: "redis"
|
||||||
port: 6379
|
port: 6379
|
||||||
password: "{{.IsRedisPass}}"
|
password: "{{.IsRedisPass}}"{{end}}
|
||||||
{{end}}
|
|
||||||
|
|||||||
@@ -71,9 +71,12 @@ const (
|
|||||||
Undefined SupportedContainer = "undefined"
|
Undefined SupportedContainer = "undefined"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var redisFlag *bool
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
||||||
|
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||||
@@ -491,13 +494,13 @@ func collectUserInput() Config {
|
|||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
if config.IsEnterprise {
|
if config.IsEnterprise {
|
||||||
config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.")
|
if *redisFlag {
|
||||||
if config.IsRedis {
|
config.IsRedis = true
|
||||||
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false)
|
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
|
||||||
if config.IsPostgreSQL {
|
if config.IsPostgreSQL {
|
||||||
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
||||||
}
|
}
|
||||||
@@ -544,7 +547,7 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||||
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
|
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
|
|||||||
@@ -2046,6 +2046,7 @@
|
|||||||
"requireDeviceApproval": "Require Device Approvals",
|
"requireDeviceApproval": "Require Device Approvals",
|
||||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||||
"sshSettings": "SSH Settings",
|
"sshSettings": "SSH Settings",
|
||||||
|
"sshAccess": "SSH Access",
|
||||||
"rdpSettings": "RDP Settings",
|
"rdpSettings": "RDP Settings",
|
||||||
"vncSettings": "VNC Settings",
|
"vncSettings": "VNC Settings",
|
||||||
"sshServer": "SSH Server",
|
"sshServer": "SSH Server",
|
||||||
|
|||||||
@@ -665,7 +665,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let targets: SubnetProxyTargetV2[] = [];
|
const targets: SubnetProxyTargetV2[] = [];
|
||||||
|
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export async function getTraefikConfig(
|
|||||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
|
||||||
|
allowBrowserGatewayResources = true
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
@@ -240,7 +241,7 @@ export async function getTraefikConfig(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resource.http) {
|
if (resource.mode === "http") {
|
||||||
if (!resource.domainId || !resource.fullDomain) {
|
if (!resource.domainId || !resource.fullDomain) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -572,7 +573,7 @@ export async function getTraefikConfig(
|
|||||||
serviceName
|
serviceName
|
||||||
].loadBalancer.serversTransport = transportName;
|
].loadBalancer.serversTransport = transportName;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (resource.mode === "tcp" || resource.mode === "udp") {
|
||||||
// Non-HTTP (TCP/UDP) configuration
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
if (!resource.enableProxy || !resource.proxyPort) {
|
if (!resource.enableProxy || !resource.proxyPort) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
322
server/middlewares/verifySiteAccess.test.ts
Normal file
322
server/middlewares/verifySiteAccess.test.ts
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import { assertEquals } from "@test/assert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the cross-organization site binding prevention in verifySiteAccess.
|
||||||
|
*
|
||||||
|
* verifySiteAccess now includes a check: if req.userOrgId is already set by a
|
||||||
|
* previous middleware (e.g. verifyResourceAccess or verifyTargetAccess), and the
|
||||||
|
* loaded site's orgId differs from req.userOrgId, the request is rejected with
|
||||||
|
* 403 Forbidden.
|
||||||
|
*
|
||||||
|
* Route stacks after fix:
|
||||||
|
* PUT /resource/:resourceId/target
|
||||||
|
* → verifyResourceAccess → verifySiteAccess → verifyLimits → ...
|
||||||
|
* POST /target/:targetId
|
||||||
|
* → verifyTargetAccess → verifySiteAccess → verifyLimits → ...
|
||||||
|
*
|
||||||
|
* verifyResourceAccess sets req.userOrgId to the resource's org.
|
||||||
|
* verifyTargetAccess sets req.userOrgId to the target's resource org.
|
||||||
|
* verifySiteAccess then checks site.orgId against req.userOrgId before
|
||||||
|
* overwriting it with the site's org.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Core org-matching logic (mirrors the check in verifySiteAccess) ---
|
||||||
|
function siteOrgMatchesExpectedOrg(
|
||||||
|
siteOrgId: string | null | undefined,
|
||||||
|
expectedOrgId: string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!siteOrgId || !expectedOrgId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return siteOrgId === expectedOrgId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates the condition check in verifySiteAccess:
|
||||||
|
// if (req.userOrgId && site.orgId !== req.userOrgId) { reject }
|
||||||
|
function shouldRejectCrossOrgSite(
|
||||||
|
siteOrgId: string,
|
||||||
|
reqUserOrgId: string | undefined
|
||||||
|
): boolean {
|
||||||
|
// The actual check in verifySiteAccess is:
|
||||||
|
// if (req.userOrgId && site.orgId !== req.userOrgId) { reject }
|
||||||
|
return !!(reqUserOrgId && siteOrgId !== reqUserOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
function testSiteOrgMatchLogic() {
|
||||||
|
console.log("Running verifySiteAccess org-match logic tests...");
|
||||||
|
|
||||||
|
// Test 1: Same org — should match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg(
|
||||||
|
"org-attacker",
|
||||||
|
"org-attacker"
|
||||||
|
);
|
||||||
|
assertEquals(result, true, "Same org should match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Different org — should NOT match (cross-org bypass scenario)
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg("org-victim", "org-attacker");
|
||||||
|
assertEquals(
|
||||||
|
result,
|
||||||
|
false,
|
||||||
|
"Cross-org site should NOT match expected org"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Site orgId is null — should NOT match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg(null, "org-attacker");
|
||||||
|
assertEquals(result, false, "Null site orgId should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Expected orgId is null — should NOT match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg("org-attacker", null);
|
||||||
|
assertEquals(result, false, "Null expected orgId should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Both null — should NOT match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg(null, null);
|
||||||
|
assertEquals(result, false, "Both null should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Empty string orgIds — should NOT match (empty string is falsy)
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg("", "org-attacker");
|
||||||
|
assertEquals(result, false, "Empty site orgId should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: Undefined orgIds — should NOT match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg(undefined, "org-attacker");
|
||||||
|
assertEquals(result, false, "Undefined site orgId should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All verifySiteAccess org-match logic tests passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function testShouldRejectCrossOrgSite() {
|
||||||
|
console.log(
|
||||||
|
"Running shouldRejectCrossOrgSite tests (mirrors verifySiteAccess check)..."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: No prior org context (undefined) — should NOT reject
|
||||||
|
// This is the normal case for site-only routes (e.g. PUT /site/:siteId)
|
||||||
|
// where verifySiteAccess runs without a prior verifyResourceAccess.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite("org-victim", undefined);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
false,
|
||||||
|
"No prior org context should NOT reject (normal site routes)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Same org — should NOT reject
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org-attacker",
|
||||||
|
"org-attacker"
|
||||||
|
);
|
||||||
|
assertEquals(shouldReject, false, "Same org should NOT reject");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Different org — should reject
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org-victim",
|
||||||
|
"org-attacker"
|
||||||
|
);
|
||||||
|
assertEquals(shouldReject, true, "Cross-org site should be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Empty string userOrgId — should NOT reject (falsy, check is skipped)
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite("org-victim", "");
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
false,
|
||||||
|
"Empty string userOrgId should NOT reject (check is skipped)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All shouldRejectCrossOrgSite tests passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Route stack validation tests ---
|
||||||
|
|
||||||
|
function testRouteStackOrdering() {
|
||||||
|
console.log("Running route stack ordering tests...");
|
||||||
|
|
||||||
|
const createTargetStack = [
|
||||||
|
"verifyResourceAccess",
|
||||||
|
"verifySiteAccess",
|
||||||
|
"verifyLimits",
|
||||||
|
"verifyUserHasAction",
|
||||||
|
"logActionAudit",
|
||||||
|
"createTarget"
|
||||||
|
];
|
||||||
|
|
||||||
|
const updateTargetStack = [
|
||||||
|
"verifyTargetAccess",
|
||||||
|
"verifySiteAccess",
|
||||||
|
"verifyLimits",
|
||||||
|
"verifyUserHasAction",
|
||||||
|
"logActionAudit",
|
||||||
|
"updateTarget"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verify verifySiteAccess comes after resource/target access middleware
|
||||||
|
{
|
||||||
|
const siteAccessIndex = createTargetStack.indexOf("verifySiteAccess");
|
||||||
|
const resourceAccessIndex = createTargetStack.indexOf(
|
||||||
|
"verifyResourceAccess"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
siteAccessIndex > resourceAccessIndex,
|
||||||
|
true,
|
||||||
|
"verifySiteAccess must come after verifyResourceAccess in create target stack"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const siteAccessIndex = updateTargetStack.indexOf("verifySiteAccess");
|
||||||
|
const targetAccessIndex =
|
||||||
|
updateTargetStack.indexOf("verifyTargetAccess");
|
||||||
|
assertEquals(
|
||||||
|
siteAccessIndex > targetAccessIndex,
|
||||||
|
true,
|
||||||
|
"verifySiteAccess must come after verifyTargetAccess in update target stack"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify verifySiteAccess comes before the handler
|
||||||
|
{
|
||||||
|
const siteAccessIndex = createTargetStack.indexOf("verifySiteAccess");
|
||||||
|
const handlerIndex = createTargetStack.indexOf("createTarget");
|
||||||
|
assertEquals(
|
||||||
|
siteAccessIndex < handlerIndex,
|
||||||
|
true,
|
||||||
|
"verifySiteAccess must come before createTarget handler"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const siteAccessIndex = updateTargetStack.indexOf("verifySiteAccess");
|
||||||
|
const handlerIndex = updateTargetStack.indexOf("updateTarget");
|
||||||
|
assertEquals(
|
||||||
|
siteAccessIndex < handlerIndex,
|
||||||
|
true,
|
||||||
|
"verifySiteAccess must come before updateTarget handler"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All route stack ordering tests passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security scenario tests ---
|
||||||
|
|
||||||
|
function testSecurityScenarios() {
|
||||||
|
console.log("Running security scenario tests...");
|
||||||
|
|
||||||
|
// Scenario 1: Attacker has resource access in org_attacker, but tries to
|
||||||
|
// bind target to a site in org_victim.
|
||||||
|
// verifyResourceAccess passes (sets req.userOrgId = "org_attacker").
|
||||||
|
// verifySiteAccess loads site (org_victim), checks site.orgId !== req.userOrgId.
|
||||||
|
// Expected: 403 Forbidden.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_victim",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
true,
|
||||||
|
"Scenario 1: Cross-org site binding must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 2: Attacker has resource access AND site access in another org.
|
||||||
|
// Even though the user has site access, verifySiteAccess rejects because
|
||||||
|
// the org-match check runs before the site access check.
|
||||||
|
// Expected: 403 Forbidden (org mismatch caught before site access check).
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_victim",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
true,
|
||||||
|
"Scenario 2: Cross-org site must be rejected even if user has site access"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 3: Legitimate user creates target with site in same org.
|
||||||
|
// verifyResourceAccess passes, verifySiteAccess org-match passes (same org),
|
||||||
|
// verifySiteAccess site access passes.
|
||||||
|
// Expected: 201 Created.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_attacker",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
false,
|
||||||
|
"Scenario 3: Same-org site must be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 4: WireGuard site in victim org — org mismatch is caught before
|
||||||
|
// any DB write, pickPort, addPeer, or addTargets side effect.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_victim",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
true,
|
||||||
|
"Scenario 4: WireGuard cross-org site must be rejected before addPeer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 5: Newt site in victim org — same as scenario 4 but for newt.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_victim",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
true,
|
||||||
|
"Scenario 5: Newt cross-org site must be rejected before addTargets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 6: Normal site-only route (e.g. PUT /site/:siteId) where
|
||||||
|
// verifySiteAccess runs without a prior verifyResourceAccess.
|
||||||
|
// req.userOrgId is undefined, so the org-match check is skipped.
|
||||||
|
// Normal site access verification proceeds.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite("org_victim", undefined);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
false,
|
||||||
|
"Scenario 6: Site-only routes should skip org-match check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All security scenario tests passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
testSiteOrgMatchLogic();
|
||||||
|
testShouldRejectCrossOrgSite();
|
||||||
|
testRouteStackOrdering();
|
||||||
|
testSecurityScenarios();
|
||||||
@@ -71,6 +71,15 @@ export async function verifySiteAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.userOrgId && site.orgId !== req.userOrgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this site"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
// Get user's role ID in the organization
|
// Get user's role ID in the organization
|
||||||
const userOrgRole = await db
|
const userOrgRole = await db
|
||||||
@@ -128,10 +137,7 @@ export async function verifySiteAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleSites.siteId, site.siteId),
|
eq(roleSites.siteId, site.siteId),
|
||||||
inArray(
|
inArray(roleSites.roleId, req.userOrgRoleIds!)
|
||||||
roleSites.roleId,
|
|
||||||
req.userOrgRoleIds!
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|||||||
@@ -493,16 +493,29 @@ export async function getTraefikConfig(
|
|||||||
const transportName = `${key}-transport`;
|
const transportName = `${key}-transport`;
|
||||||
const headersMiddlewareName = `${key}-headers-middleware`;
|
const headersMiddlewareName = `${key}-headers-middleware`;
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Processing resource ${resource.name} with domain ${fullDomain} and ${targets.length} targets`
|
||||||
|
);
|
||||||
|
|
||||||
if (!resource.enabled) {
|
if (!resource.enabled) {
|
||||||
|
logger.debug(
|
||||||
|
`Resource ${resource.name} is disabled, skipping Traefik config`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resource.http) {
|
if (resource.mode == "http") {
|
||||||
if (!resource.domainId) {
|
if (!resource.domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`Resource ${resource.name} does not have a domainId, skipping Traefik config`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.fullDomain) {
|
if (!resource.fullDomain) {
|
||||||
|
logger.debug(
|
||||||
|
`Resource ${resource.name} does not have a fullDomain, skipping Traefik config`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,7 +971,7 @@ export async function getTraefikConfig(
|
|||||||
serviceName
|
serviceName
|
||||||
].loadBalancer.serversTransport = transportName;
|
].loadBalancer.serversTransport = transportName;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (resource.mode == "tcp" || resource.mode == "udp") {
|
||||||
// Non-HTTP (TCP/UDP) configuration
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
if (!resource.enableProxy) {
|
if (!resource.enableProxy) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { randomInt } from "crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
actionAuditLog,
|
actionAuditLog,
|
||||||
@@ -392,7 +393,7 @@ export async function signSshKey(
|
|||||||
if (existingUserWithSameName) {
|
if (existingUserWithSameName) {
|
||||||
let foundUniqueUsername = false;
|
let foundUniqueUsername = false;
|
||||||
for (let attempt = 0; attempt < 20; attempt++) {
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
const randomNum = randomInt(0, 101); // 0 to 100
|
||||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||||
|
|
||||||
const [existingUser] = await db
|
const [existingUser] = await db
|
||||||
|
|||||||
@@ -561,6 +561,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/resource/:resourceId/target",
|
"/resource/:resourceId/target",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifySiteAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createTarget),
|
verifyUserHasAction(ActionsEnum.createTarget),
|
||||||
logActionAudit(ActionsEnum.createTarget),
|
logActionAudit(ActionsEnum.createTarget),
|
||||||
@@ -612,6 +613,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
|
verifySiteAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateTarget),
|
verifyUserHasAction(ActionsEnum.updateTarget),
|
||||||
logActionAudit(ActionsEnum.updateTarget),
|
logActionAudit(ActionsEnum.updateTarget),
|
||||||
@@ -1234,7 +1236,8 @@ export const authRouter = Router();
|
|||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
authRouter.use(
|
authRouter.use(
|
||||||
rateLimit({
|
rateLimit({
|
||||||
windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
|
windowMs:
|
||||||
|
config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
|
||||||
max: config.getRawConfig().rate_limits.auth.max_requests,
|
max: config.getRawConfig().rate_limits.auth.max_requests,
|
||||||
keyGenerator: (req) =>
|
keyGenerator: (req) =>
|
||||||
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
|
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
// Notify parent of changes (create mode)
|
// Notify parent of changes (create mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChange?.(targets);
|
onChange?.(targets);
|
||||||
}, [targets]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [targets]);
|
||||||
|
|
||||||
// Poll health status only in edit mode
|
// Poll health status only in edit mode
|
||||||
const { data: polledTargets } = useQuery({
|
const { data: polledTargets } = useQuery({
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function Page(props: {
|
|||||||
targetOrgId = lastOrgCookie;
|
targetOrgId = lastOrgCookie;
|
||||||
} else {
|
} else {
|
||||||
let ownedOrg = orgs.find((org) => org.isOwner);
|
let ownedOrg = orgs.find((org) => org.isOwner);
|
||||||
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
|
const primaryOrg = orgs.find((org) => org.isPrimaryOrg);
|
||||||
if (!ownedOrg) {
|
if (!ownedOrg) {
|
||||||
if (primaryOrg) {
|
if (primaryOrg) {
|
||||||
ownedOrg = primaryOrg;
|
ownedOrg = primaryOrg;
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export const metadata: Metadata = {
|
|||||||
export default async function MaintenanceScreen() {
|
export default async function MaintenanceScreen() {
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
let title = t("privateMaintenanceScreenTitle");
|
const title = t("privateMaintenanceScreenTitle");
|
||||||
let message = t("privateMaintenanceScreenMessage");
|
const message = t("privateMaintenanceScreenMessage");
|
||||||
let steps = t("privateMaintenanceScreenSteps");
|
const steps = t("privateMaintenanceScreenSteps");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default async function RdpPage() {
|
|||||||
const hostname = host.split(":")[0];
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
let target: GetBrowserTargetResponse | null = null;
|
let target: GetBrowserTargetResponse | null = null;
|
||||||
let error: string | null = null;
|
const error: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
|||||||
@@ -180,7 +180,6 @@ export default function SshClient({
|
|||||||
certificate: signedKeyData.certificate
|
certificate: signedKeyData.certificate
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function connect(override?: ConnectCredentials) {
|
function connect(override?: ConnectCredentials) {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export default function VncClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const rfbRef = useRef<any>(null);
|
const rfbRef = useRef<any>(null);
|
||||||
const screenRef = useRef<HTMLDivElement>(null);
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ export default function VncClient({
|
|||||||
// Clean up on unmount.
|
// Clean up on unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => disconnect();
|
return () => disconnect();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []);
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async () => {
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -115,7 +114,6 @@ export default function VncClient({
|
|||||||
options.credentials = { password: form.password };
|
options.credentials = { password: form.password };
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const rfb: any = new RFB(screenRef.current, wsUrl, options);
|
const rfb: any = new RFB(screenRef.current, wsUrl, options);
|
||||||
|
|
||||||
rfb.scaleViewport = true;
|
rfb.scaleViewport = true;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default async function VncPage() {
|
|||||||
const hostname = host.split(":")[0];
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
let target: GetBrowserTargetResponse | null = null;
|
let target: GetBrowserTargetResponse | null = null;
|
||||||
let error: string | null = null;
|
const error: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
|||||||
Reference in New Issue
Block a user