Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/codeql/codeql-pack.lock.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
lockVersion: 1.0.0
dependencies:
codeql/concepts:
version: 0.0.18
codeql/controlflow:
version: 2.0.28
codeql/dataflow:
version: 2.1.0
codeql/javascript-all:
version: 2.6.24
codeql/mad:
version: 1.0.44
codeql/regex:
version: 1.0.44
codeql/ssa:
version: 2.0.20
codeql/threat-models:
version: 1.0.44
codeql/tutorial:
version: 1.0.44
codeql/typetracking:
version: 2.0.28
codeql/util:
version: 2.0.31
codeql/xml:
version: 1.0.44
codeql/yaml:
version: 1.0.44
compiled: false
9 changes: 9 additions & 0 deletions .github/codeql/qlpack.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name: fieldtrack/security-queries
version: 1.0.0
description: >
Custom CodeQL queries for the FieldTrack 2.0 backend.
Targets Fastify + Supabase multi-tenant SaaS patterns:
tenant isolation, JWT claim misuse, and missing role guards.
library: false
dependencies:
codeql/javascript-all: '*'
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @name Fastify EMPLOYEE-only route missing role guard
* @description A Fastify route that creates or accesses employee-scoped
* resources (attendance, expenses) uses only authenticate in
* preValidation but no requireRole("EMPLOYEE") guard.
* ADMIN users can call these routes; the only protection is
* a service-layer check, which violates defense-in-depth.
* @kind problem
* @problem.severity warning
* @id fieldtrack/fastify-employee-route-missing-role-guard
* @tags security
* authentication
* fastify
* @precision medium
*/
import javascript

// ─── Helpers ────────────────────────────────────────────────────────────────

predicate arrayContainsRequireRole(Expr arrayExpr) {
exists(CallExpr requireRoleCall |
requireRoleCall.getCallee().(Identifier).getName() = "requireRole" and
requireRoleCall = arrayExpr.(ArrayExpr).getAnElement()
)
}

predicate optionsHaveRoleGuard(ObjectExpr options) {
exists(Property preValidation |
preValidation.getParent() = options and
preValidation.getName() = "preValidation" and
arrayContainsRequireRole(preValidation.getInit())
)
}

// ─── Query ───────────────────────────────────────────────────────────────────

from MethodCallExpr routeReg, StringLiteral path, ObjectExpr options
where
routeReg.getMethodName() in ["get", "post", "put", "patch", "delete"] and
path = routeReg.getArgument(0) and
options = routeReg.getArgument(1) and

// Employee-scoped resource paths (not admin, not health, not internal)
(
path.getStringValue().matches("%/attendance/%") or
path.getStringValue().matches("%/expenses%") or
path.getStringValue().matches("%/locations/%")
) and
not path.getStringValue().matches("%/admin/%") and

// Has authenticate but no requireRole
exists(Property preValidation, ArrayExpr arr |
preValidation.getParent() = options and
preValidation.getName() = "preValidation" and
arr = preValidation.getInit() and
exists(Expr elem |
elem = arr.getAnElement() and
elem.(Identifier).getName() = "authenticate"
)
) and

not optionsHaveRoleGuard(options)

select routeReg,
"Route '" + path.getStringValue() +
"' operates on employee-scoped data but has no requireRole() guard. " +
"Consider adding requireRole(\"EMPLOYEE\") for defense-in-depth."
70 changes: 70 additions & 0 deletions .github/codeql/queries/fastify-missing-role-guard.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @name Fastify route missing requireRole guard
* @description A Fastify route under /admin/ or with destructive methods has
* authenticate in preValidation but no requireRole() call.
* Any authenticated user with any role can invoke it.
* @kind problem
* @problem.severity error
* @id fieldtrack/fastify-missing-role-guard
* @tags security
* authentication
* fastify
* @precision high
*/
import javascript

// ─── Helpers ────────────────────────────────────────────────────────────────

/**
* Returns true if the given expression is (or evaluates to) an array literal
* that contains a call to requireRole(…).
*/
predicate arrayContainsRequireRole(Expr arrayExpr) {
exists(CallExpr requireRoleCall |
requireRoleCall.getCallee().(Identifier).getName() = "requireRole" and
requireRoleCall = arrayExpr.(ArrayExpr).getAnElement()
)
}

/**
* Returns true if the options object passed to the route registration
* contains a preValidation array that calls requireRole(…).
*/
predicate optionsHaveRoleGuard(ObjectExpr options) {
exists(Property preValidation |
preValidation.getParent() = options and
preValidation.getName() = "preValidation" and
arrayContainsRequireRole(preValidation.getInit())
)
}

// ─── Query ───────────────────────────────────────────────────────────────────

from MethodCallExpr routeReg, StringLiteral path, ObjectExpr options
where
// Match Fastify route helper calls: app.get / app.post / app.patch etc.
routeReg.getMethodName() in ["get", "post", "put", "patch", "delete"] and
path = routeReg.getArgument(0) and
options = routeReg.getArgument(1) and

// Only flag /admin/ paths β€” these definitely require ADMIN role
path.getStringValue().matches("%/admin/%") and

// The route DOES include authenticate (so it is not a public route)
exists(Property preValidation, ArrayExpr arr |
preValidation.getParent() = options and
preValidation.getName() = "preValidation" and
arr = preValidation.getInit() and
exists(Expr elem |
elem = arr.getAnElement() and
elem.(Identifier).getName() = "authenticate"
)
) and

// But does NOT include requireRole(…)
not optionsHaveRoleGuard(options)

select routeReg,
"Admin route '" + path.getStringValue() +
"' has authenticate but no requireRole() in preValidation. " +
"Any authenticated user β€” regardless of role β€” can call it."
40 changes: 40 additions & 0 deletions .github/codeql/queries/jwt-user-metadata-access.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @name JWT user_metadata accessed for authorization
* @description user_metadata in a Supabase JWT is controlled by the end user.
* Using it for authorization decisions (role, org_id, employee_id)
* is a security vulnerability β€” attackers can forge these values.
* Auth claims must come from app_metadata (server-controlled) or
* top-level claims injected by the custom_access_token_hook.
* @kind problem
* @problem.severity error
* @id fieldtrack/jwt-user-metadata-trust
* @tags security
* jwt
* authentication
* @precision high
*/
import javascript

// ─── Query ───────────────────────────────────────────────────────────────────

/*
* Matches any property access of the form:
* decoded.user_metadata.<anything>
* payload.user_metadata.role
* token.user_metadata.org_id
* …where the outer access reads an authorization-sensitive field.
*/
from PropAccess userMetaAccess, PropAccess outerAccess, string authField
where
// The inner access is .user_metadata on any identifier
userMetaAccess.getPropertyName() = "user_metadata" and

// The outer access reads a security-sensitive field from user_metadata
outerAccess.getBase() = userMetaAccess and
authField = outerAccess.getPropertyName() and
authField in ["role", "org_id", "organization_id", "employee_id", "is_admin", "permissions"]

select outerAccess,
"Authorization field '" + authField + "' is read from user_metadata, " +
"which is user-controlled. Use app_metadata." + authField +
" or the top-level JWT claim injected by the Supabase auth hook instead."
154 changes: 154 additions & 0 deletions .github/codeql/queries/supabase-missing-tenant-filter.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* @name Supabase query missing organization_id tenant filter
* @description A direct Supabase .from() call is not wrapped by tenantQuery()
* or enforceTenant(), and does not have a chained
* .eq("organization_id", …) call. This can cause cross-tenant
* data exposure in the multi-tenant SaaS model where
* supabaseServiceClient bypasses RLS.
* @kind problem
* @problem.severity error
* @id fieldtrack/supabase-missing-tenant-filter
* @tags security
* multi-tenant
* supabase
* @precision medium
*/
import javascript

// ─── Helpers ────────────────────────────────────────────────────────────────

/**
* A call to supabase.from("table_name") using the service-role client.
* Matches both variable names used in the codebase:
* supabaseServiceClient.from(…)
* supabase.from(…) (local alias)
*/
class SupabaseFromCall extends MethodCallExpr {
SupabaseFromCall() {
this.getMethodName() = "from" and
(
this.getReceiver().(VarAccess).getName().regexpMatch("supabase.*") or
this.getReceiver().(Identifier).getName().regexpMatch("supabase.*")
)
}

string getTableName() {
result = this.getArgument(0).(StringLiteral).getStringValue()
}
}

/**
* Tables that are INTENTIONALLY global (no org scope):
* organizations β€” org lookup by id, never multi-tenant filtered
* users / auth β€” Supabase-managed
* queue_retry_intents β€” internal ops table (see issue C1 for the fix)
*
* Add known-safe tables here to reduce false positives.
*/
predicate isGlobalTable(string tableName) {
tableName in [
"organizations",
"storage.objects"
]
}

/**
* Returns true if an .eq("organization_id", …) call is chained anywhere
* in the method call chain rooted at `base`.
*/
predicate hasOrganizationIdEq(MethodCallExpr base) {
exists(MethodCallExpr eqCall |
eqCall.getMethodName() = "eq" and
eqCall.getArgument(0).(StringLiteral).getStringValue() = "organization_id" and
(
// Direct chain: base.select(...).eq("organization_id", ...)
eqCall.getReceiver+() = base or
// Reverse: the from() call is the receiver chain leading to eqCall
base.getReceiver+() = eqCall
)
)
}

/**
* Returns true if the from() call is passed as an argument to tenantQuery(),
* enforceTenant(), or orgTable() β€” the approved isolation wrappers.
*
* orgTable() is the preferred wrapper in repository files: it constructs the
* Supabase query and applies .eq("organization_id", ...) at construction time,
* so the from() call never appears with a chained .eq() β€” the ql predicate must
* treat orgTable() calls as implicitly tenant-scoped.
*
* Note: INSERT/UPSERT operations legitimately call supabase.from() directly
* and set organization_id in the body payload, not as a .eq() filter.
* Those are flagged by hasOrganizationIdInPayload() below and suppressed.
*/
predicate isWrappedInTenantHelper(SupabaseFromCall fromCall) {
// Pattern 1: tenantQuery(request, supabase.from("x"))
exists(CallExpr wrapper |
wrapper.getCallee().(Identifier).getName() in ["tenantQuery", "enforceTenant"] and
wrapper.getAnArgument() = fromCall
)
or
// Pattern 2: tenantQuery(request, supabase.from("x").select("*"))
exists(CallExpr wrapper, MethodCallExpr chain |
wrapper.getCallee().(Identifier).getName() in ["tenantQuery", "enforceTenant"] and
chain.getReceiver+() = fromCall and
wrapper.getAnArgument() = chain
)
or
// Pattern 3: orgTable(request, "table_name")
// This wrapper does NOT call supabase.from() inline β€” it is a factory that
// internally calls supabase.from() + .eq("organization_id", ...).
// From the call-site perspective the findable pattern is:
// orgTable(request, "employees").select("*")
// The supabase.from() call inside orgTable's implementation IS flagged but
// it belongs to a trusted utility β€” suppress it by file path.
fromCall.getFile().getAbsolutePath().matches("%db/query%")
}

/**
* Suppress findings in files that are intentionally cross-tenant:
* workers/ β€” BullMQ workers receive job payloads with pre-validated session IDs.
* They operate globally to process jobs from any org. Tenant scoping
* happens at the job-enqueue boundary, not inside the worker.
* scripts/ β€” One-off backfill/migration scripts that purposefully touch all orgs.
* plugins/prometheus.ts β€” Global metric aggregation (no per-org scope by design).
*/
predicate isIntentionallyGlobalContext(SupabaseFromCall fromCall) {
fromCall.getFile().getAbsolutePath().matches("%/workers/%") or
fromCall.getFile().getAbsolutePath().matches("%\\workers\\%") or
fromCall.getFile().getAbsolutePath().matches("%/scripts/%") or
fromCall.getFile().getAbsolutePath().matches("%\\scripts\\%") or
fromCall.getFile().getAbsolutePath().matches("%prometheus%")
}

// ─── Query ───────────────────────────────────────────────────────────────────

/**
* Returns true when the Supabase call chain contains an INSERT/UPSERT (.insert
* or .upsert) where the payload object has an "organization_id" property.
* These are secure by construction and should not be flagged.
*/
predicate isInsertWithOrgId(SupabaseFromCall fromCall) {
exists(MethodCallExpr mutationCall, ObjectExpr payload, Property orgProp |
mutationCall.getMethodName() in ["insert", "upsert"] and
mutationCall.getReceiver+() = fromCall and
// First argument to insert/upsert is the payload object
payload = mutationCall.getArgument(0) and
orgProp.getParent() = payload and
orgProp.getName() = "organization_id"
)
}

from SupabaseFromCall fromCall
where
not isGlobalTable(fromCall.getTableName()) and
not isWrappedInTenantHelper(fromCall) and
not hasOrganizationIdEq(fromCall) and
not isInsertWithOrgId(fromCall) and
not isIntentionallyGlobalContext(fromCall)

select fromCall,
"Supabase query on '" + fromCall.getTableName() +
"' uses the service-role client and is not scoped by organization_id. " +
"Wrap with tenantQuery() or add .eq(\"organization_id\", …)."
Loading
Loading