Compare commits

..

8 Commits

Author SHA1 Message Date
Owen Schwartz 8b50f1fb65 Merge pull request #3218 from fosrl/dev
Fix installer
2026-06-04 11:21:59 -07:00
Owen 2d78a4b628 Fix installer 2026-06-04 11:21:40 -07:00
Owen Schwartz 527d4cc777 Merge pull request #3215 from fosrl/dev
1.19.0-rc.0
2026-06-04 10:34:20 -07:00
Owen Schwartz 01361884eb Potential fix for pull request finding 'CodeQL / Insecure randomness'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-04 10:33:15 -07:00
Owen 6c4cbcab5d Fix eslint errors 2026-06-04 10:22:29 -07:00
Owen Schwartz aac25f0a53 Merge pull request #3214 from marcschaeferger/dev
Prevent cross-org site binding in target create/update
2026-06-04 10:11:53 -07:00
Marc Schäfer f617f93a94 test(middleware): add regression tests for cross-org site binding prevention
Test the org-match logic in verifySiteAccess:
- Same org: allowed
- Cross-org: rejected with 403
- No prior org context (site-only routes): check skipped, normal flow

Test route stack ordering:
- verifySiteAccess runs after verifyResourceAccess/verifyTargetAccess
- verifySiteAccess runs before the target create/update handler

Test security scenarios for both WireGuard and newt site types.

Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-05-29 22:57:39 +00:00
Marc Schäfer 51629247a5 fix(middleware): prevent cross-org site binding in target create/update
Extend verifySiteAccess to check that when req.userOrgId is already set
by a prior middleware (e.g. verifyResourceAccess/verifyTargetAccess), the
site from req.body.siteId belongs to the same organization. This prevents
the cross-organization tunnel boundary bypass where an attacker with
resource access in one org binds that resource's target to a site in
another org.

Add verifySiteAccess to both target route stacks:
- PUT /resource/:resourceId/target (after verifyResourceAccess)
- POST /target/:targetId (after verifyTargetAccess)

The org-match check runs before req.userOrg is overwritten, so the
resource's organization context is preserved for comparison.

Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-05-29 22:44:16 +00:00
29 changed files with 378 additions and 89 deletions
+2 -4
View File
@@ -38,7 +38,5 @@ flags:
disable_user_create_org: false
allow_raw_resources: true
{{if .IsPostgreSQL}}
postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin
{{end}}
{{if .IsPostgreSQL}}postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
+20 -33
View File
@@ -7,23 +7,17 @@ services:
deploy:
resources:
limits:
memory: 1g
memory: 2g
reservations:
memory: 256m
{{if or .IsPostgreSQL .IsRedis}}
depends_on:
{{if .IsPostgreSQL}}
postgres:
condition: service_healthy
{{end}}
{{if .IsRedis}}
redis:
condition: service_healthy
{{end}}
memory: 512m
{{if or .IsPostgreSQL .IsRedis}}depends_on:
{{if .IsPostgreSQL}}postgres:
condition: service_healthy{{end}}
{{if .IsRedis}}redis:
condition: service_healthy{{end}}
networks:
- default
- backend
{{end}}
- backend{{end}}
volumes:
- ./config:/app/config
healthcheck:
@@ -31,8 +25,8 @@ services:
interval: "10s"
timeout: "10s"
retries: 15
{{if .InstallGerbil}}
gerbil:
{{if .InstallGerbil}}gerbil:
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil
restart: unless-stopped
@@ -53,17 +47,16 @@ services:
- 21820:21820/udp
- 443:443
- 443:443/udp # For http3 QUIC if desired
- 80:80
{{end}}
- 80:80{{end}}
traefik:
image: docker.io/traefik:v3.6
container_name: traefik
restart: unless-stopped
{{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:
- 443:443
- 80:80
{{end}}
- 80:80{{end}}
depends_on:
pangolin:
condition: service_healthy
@@ -74,8 +67,7 @@ services:
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
{{if .IsPostgreSQL}}
postgres:
{{if .IsPostgreSQL}}postgres:
image: postgres:18
container_name: postgres
restart: unless-stopped
@@ -91,11 +83,9 @@ services:
timeout: 5s
retries: 5
networks:
- backend
{{end}}
- backend{{end}}
{{if .IsRedis}}
redis:
{{if .IsRedis}}redis:
image: redis:8-trixie
container_name: redis
restart: unless-stopped
@@ -113,17 +103,14 @@ services:
retries: 3
start_period: 10s
networks:
- backend
{{end}}
- backend{{end}}
networks:
default:
driver: bridge
name: pangolin_frontend
{{if .EnableIPv6}} enable_ipv6: true{{end}}
{{if or .IsPostgreSQL .IsRedis}}
backend:
{{if or .IsPostgreSQL .IsRedis}} backend:
driver: bridge
name: pangolin_backend
internal: true
{{end}}
internal: true{{end}}
+2 -4
View File
@@ -1,6 +1,4 @@
{{if .IsRedis}}
redis:
{{if .IsRedis}}redis:
host: "redis"
port: 6379
password: "{{.IsRedisPass}}"
{{end}}
password: "{{.IsRedisPass}}"{{end}}
+7 -4
View File
@@ -71,9 +71,12 @@ const (
Undefined SupportedContainer = "undefined"
)
var redisFlag *bool
func main() {
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()
// 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.")
if config.IsEnterprise {
config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.")
if config.IsRedis {
if *redisFlag {
config.IsRedis = true
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 {
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
}
@@ -544,7 +547,7 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===")
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 == "" {
fmt.Println("Error: Dashboard Domain name is required")
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Сигурни ли сте, че искате да отвържете този доставчик на самоличност от тази организация?",
"idpUnassociateDescription": "Всички потребители, свързани с този доставчик на самоличност, ще бъдат премахнати от тази организация, но доставчика на самоличност ще продължи да съществува за други свързани организации.",
"idpUnassociateConfirm": "Потвърдете отвързване на доставчика на самоличност",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Това не може да бъде отменено за тази организация.",
"idpUnassociatedDescription": "Доставчика на самоличност е успешно отвързан от тази организация",
"idpUnassociateMenu": "Отвързване",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Opravdu chcete odpojit tohoto poskytovatele identity od této organizace?",
"idpUnassociateDescription": "Všichni uživatelé spojení s tímto poskytovatelem identity budou odstraněni z této organizace, ale poskytovatel identity zůstane nadále existovat pro ostatní přidružené organizace.",
"idpUnassociateConfirm": "Potvrdit odpojení poskytovatele identity",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Toto nelze pro tuto organizaci vrátit.",
"idpUnassociatedDescription": "Poskytovatel identity byl úspěšně odpojen od této organizace",
"idpUnassociateMenu": "Odpojit",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Sind Sie sicher, dass Sie die Verknüpfung dieses Identitätsanbieters mit dieser Organisation aufheben möchten?",
"idpUnassociateDescription": "Alle Benutzer, die mit diesem Identitätsanbieter verbunden sind, werden aus dieser Organisation entfernt, aber der Identitätsanbieter bleibt für andere verbundene Organisationen weiterhin bestehen.",
"idpUnassociateConfirm": "Verknüpfung des Identitätsanbieters aufheben bestätigen",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Dies kann für diese Organisation nicht rückgängig gemacht werden.",
"idpUnassociatedDescription": "Identitätsanbieter erfolgreich von dieser Organisation gelöst",
"idpUnassociateMenu": "Verknüpfung aufheben",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "¿Está seguro de que desea desasociar este proveedor de identidad de esta organización?",
"idpUnassociateDescription": "Todos los usuarios asociados con este proveedor de identidad serán eliminados de esta organización, pero el proveedor de identidad continuará existiendo para otras organizaciones asociadas.",
"idpUnassociateConfirm": "Confirme Desasociar Proveedor de Identidad",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Esto no se puede deshacer para esta organización.",
"idpUnassociatedDescription": "Proveedor de identidad desasociado de esta organización con éxito",
"idpUnassociateMenu": "Desasociar",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Sei sicuro di voler disassociare questo provider di identità da questa organizzazione?",
"idpUnassociateDescription": "Tutti gli utenti associati a questo provider di identità verranno rimossi da questa organizzazione, ma il provider di identità continuerà ad esistere per altre organizzazioni associate.",
"idpUnassociateConfirm": "Conferma Disassociazione Provider di Identità",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Questo non può essere annullato per questa organizzazione.",
"idpUnassociatedDescription": "Provider di identità disassociato con successo da questa organizzazione",
"idpUnassociateMenu": "Disassocia",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "정말로 이 조직에서 이 아이덴티티 공급자의 연관을 해제하시겠습니까?",
"idpUnassociateDescription": "이 아이덴티티 공급자와 연관된 모든 사용자는 이 조직에서 제거될 것이지만, 아이덴티티 공급자는 다른 연관된 조직에 계속해서 존재할 것입니다.",
"idpUnassociateConfirm": "아이덴티티 공급자 연관 해제 확인",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "이 조직에서 이것은 되돌릴 수 없습니다.",
"idpUnassociatedDescription": "아이덴티티 공급자가 이 조직에서 성공적으로 연관 해제되었습니다",
"idpUnassociateMenu": "연관 해제",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Er du sikker på at du vil frakoble denne identitetsleverandøren fra denne organisasjonen?",
"idpUnassociateDescription": "Alle brukere knyttet til denne identitetsleverandøren vil bli fjernet fra denne organisasjonen, men identitetsleverandøren vil fortsatt eksistere for andre tilknyttede organisasjoner.",
"idpUnassociateConfirm": "Bekreft frakobling av identitetsleverandør",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Dette kan ikke angres for denne organisasjonen.",
"idpUnassociatedDescription": "Identitetsleverandør er vellykket frakoblet fra denne organisasjonen",
"idpUnassociateMenu": "Frakoble",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Weet u zeker dat u deze identiteitsprovider van deze organisatie wilt loskoppelen?",
"idpUnassociateDescription": "Alle gebruikers die aan deze identiteitsprovider zijn gekoppeld, worden uit deze organisatie verwijderd, maar de identiteitsprovider blijft bestaan voor andere gerelateerde organisaties.",
"idpUnassociateConfirm": "Bevestig ontkoppelen identiteitsprovider",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Dit kan niet ongedaan worden gemaakt voor deze organisatie.",
"idpUnassociatedDescription": "Identiteitsprovider succesvol losgekoppeld van deze organisatie",
"idpUnassociateMenu": "Ontkoppelen",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Czy na pewno chcesz odłączyć tego dostawcę tożsamości od tej organizacji?",
"idpUnassociateDescription": "Wszystkie użytkownicy powiązani z tym dostawcą tożsamości zostaną usunięci z tej organizacji, ale dostawca tożsamości będzie nadal istniał dla innych powiązanych organizacji.",
"idpUnassociateConfirm": "Potwierdź odłączenie dostawcy tożsamości",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Tego nie można cofnąć dla tej organizacji.",
"idpUnassociatedDescription": "Dostawca tożsamości pomyślnie odłączony od tej organizacji",
"idpUnassociateMenu": "Odłącz",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Tem certeza de que deseja desassociar este provedor de identidade desta organização?",
"idpUnassociateDescription": "Todos os usuários associados a este provedor de identidade serão removidos desta organização, mas o provedor de identidade continuará a existir para outras organizações associadas.",
"idpUnassociateConfirm": "Confirmar Desassociação do Provedor de Identidade",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Isso não pode ser desfeito para esta organização.",
"idpUnassociatedDescription": "Provedor de identidade desassociado desta organização com sucesso",
"idpUnassociateMenu": "Desassociar",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Вы уверены, что хотите рассоединить этого поставщика удостоверений с этой организацией?",
"idpUnassociateDescription": "Все пользователи, связанные с этим поставщиком удостоверений, будут удалены из этой организации, но поставщик удостоверений будет продолжать существовать для других связанных организаций.",
"idpUnassociateConfirm": "Подтвердите рассоединение поставщика удостоверений",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Это не может быть отменено для этой организации.",
"idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией",
"idpUnassociateMenu": "Рассоединить",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "Bu kimlik sağlayıcının bu kuruluştan ilişiğini kesmek istediğinizden emin misiniz?",
"idpUnassociateDescription": "Bu kimlik sağlayıcı ile ilişkilendirilen tüm kullanıcılar bu kuruluştan kaldırılacaktır, ancak kimlik sağlayıcı diğer ilişkilendirilen kuruluşlar için var olmaya devam edecektir.",
"idpUnassociateConfirm": "Kimlik Sağlayıcının İlişkisinin Kesilmesini Onayla",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "Bu işlem bu kuruluş için geri alınamaz.",
"idpUnassociatedDescription": "Kimlik sağlayıcı bu kuruluştan başarıyla ayrıldı",
"idpUnassociateMenu": "İlişkiyi Kes",
-2
View File
@@ -3346,8 +3346,6 @@
"idpUnassociateQuestion": "您确定要将此身份提供者从此组织中取消关联吗?",
"idpUnassociateDescription": "与此身份提供者关联的所有用户将从该组织中移除,但身份提供者仍会继续存在于关联的其他组织中。",
"idpUnassociateConfirm": "确认取消关联身份提供者",
"idpConfirmDeleteAndRemoveMeFromOrg": "DELETE AND REMOVE ME FROM ORG",
"idpUnassociateAndRemoveMeFromOrg": "UNASSOCIATE AND REMOVE ME FROM ORG",
"idpUnassociateWarning": "此操作无法对该组织撤销。",
"idpUnassociatedDescription": "身份提供者已成功从该组织中取消关联",
"idpUnassociateMenu": "取消关联",
+1 -1
View File
@@ -665,7 +665,7 @@ export async function generateSubnetProxyTargetV2(
return;
}
let targets: SubnetProxyTargetV2[] = [];
const targets: SubnetProxyTargetV2[] = [];
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
+322
View 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();
+10 -4
View File
@@ -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) {
// Get user's role ID in the organization
const userOrgRole = await db
@@ -128,10 +137,7 @@ export async function verifySiteAccess(
.where(
and(
eq(roleSites.siteId, site.siteId),
inArray(
roleSites.roleId,
req.userOrgRoleIds!
)
inArray(roleSites.roleId, req.userOrgRoleIds!)
)
)
.limit(1)
+2 -1
View File
@@ -12,6 +12,7 @@
*/
import { Request, Response, NextFunction } from "express";
import { randomInt } from "crypto";
import { z } from "zod";
import {
actionAuditLog,
@@ -392,7 +393,7 @@ export async function signSshKey(
if (existingUserWithSameName) {
let foundUniqueUsername = false;
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 [existingUser] = await db
+4 -1
View File
@@ -561,6 +561,7 @@ authenticated.delete(
authenticated.put(
"/resource/:resourceId/target",
verifyResourceAccess,
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createTarget),
logActionAudit(ActionsEnum.createTarget),
@@ -612,6 +613,7 @@ authenticated.get(
authenticated.post(
"/target/:targetId",
verifyTargetAccess,
verifySiteAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateTarget),
logActionAudit(ActionsEnum.updateTarget),
@@ -1234,7 +1236,8 @@ export const authRouter = Router();
unauthenticated.use("/auth", authRouter);
authRouter.use(
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,
keyGenerator: (req) =>
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
@@ -103,7 +103,7 @@ export function ProxyResourceTargetsForm({
// Notify parent of changes (create mode)
useEffect(() => {
onChange?.(targets);
}, [targets]); // eslint-disable-line react-hooks/exhaustive-deps
}, [targets]);
// Poll health status only in edit mode
const { data: polledTargets } = useQuery({
+1 -1
View File
@@ -86,7 +86,7 @@ export default async function Page(props: {
targetOrgId = lastOrgCookie;
} else {
let ownedOrg = orgs.find((org) => org.isOwner);
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
const primaryOrg = orgs.find((org) => org.isPrimaryOrg);
if (!ownedOrg) {
if (primaryOrg) {
ownedOrg = primaryOrg;
+3 -3
View File
@@ -16,9 +16,9 @@ export const metadata: Metadata = {
export default async function MaintenanceScreen() {
const t = await getTranslations();
let title = t("privateMaintenanceScreenTitle");
let message = t("privateMaintenanceScreenMessage");
let steps = t("privateMaintenanceScreenSteps");
const title = t("privateMaintenanceScreenTitle");
const message = t("privateMaintenanceScreenMessage");
const steps = t("privateMaintenanceScreenSteps");
return (
<div className="min-h-screen flex items-center justify-center p-4">
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function RdpPage() {
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
const error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
-1
View File
@@ -180,7 +180,6 @@ export default function SshClient({
certificate: signedKeyData.certificate
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function connect(override?: ConnectCredentials) {
+1 -3
View File
@@ -39,7 +39,6 @@ export default function VncClient({
});
const [connected, setConnected] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null);
@@ -59,7 +58,7 @@ export default function VncClient({
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
const connect = async () => {
if (!target) {
@@ -115,7 +114,6 @@ export default function VncClient({
options.credentials = { password: form.password };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function VncPage() {
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
const error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(