-
Notifications
You must be signed in to change notification settings - Fork 248
Feat/rbac v1 #2092
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feat/rbac v1 #2092
Conversation
- Update permissions.ts to extend defaultStatements from better-auth - Add GRC resources: control, evidence, policy, risk, vendor, task, framework, audit, finding, questionnaire, integration - Add program_manager role with full GRC access but no member management - Update owner/admin roles to extend ownerAc/adminAc from better-auth - Update auditor role with read + export permissions - Keep employee/contractor roles minimal with assignment-based access - Add ROLE_HIERARCHY, RESTRICTED_ROLES, PRIVILEGED_ROLES exports - Add placeholder for dynamicAccessControl in auth.ts (Sprint 2) Part of ENG-138: Complete Permission System Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Create PermissionGuard that calls better-auth's hasPermission API - Add fallback role-based check when better-auth is unavailable - Create @RequirePermission decorator for route-level permission checks - Create @RequirePermissions decorator for multi-resource permissions - Export GRCResource and GRCAction types for type safety - Add program_manager to Role enum in database schema - Update AuthModule to export PermissionGuard The guard: - Validates permissions via better-auth's hasPermission endpoint - Falls back to role-based check if API unavailable - Logs warnings for API key bypass (TODO: add API key scopes) - Provides static isRestrictedRole() helper for assignment filtering Part of ENG-138: Complete Permission System Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Update portal permissions.ts to match app version - Fix security issue where employee/contractor had excessive permissions - Add program_manager role to portal - Extend defaultStatements from better-auth - Add RESTRICTED_ROLES and PRIVILEGED_ROLES exports BREAKING CHANGE: Employee and contractor roles in portal now have restricted permissions matching the app. Previously they had member management and organization update permissions. Part of ENG-138: Complete Permission System Co-Authored-By: Claude Opus 4.5 <[email protected]>
Add comprehensive tests for PermissionGuard covering: - Permission bypass when no permissions required - API key bypass behavior - Role-based access for privileged vs restricted roles - Fallback behavior when better-auth API unavailable - isRestrictedRole static method for all role types Co-Authored-By: Claude Opus 4.5 <[email protected]>
Migrate all API controllers to use the new better-auth permission system: - findings.controller.ts: finding create/update/delete permissions - task-management.controller.ts: task CRUD + assign permissions - people.controller.ts: member delete permission for removeHost - evidence-export.controller.ts: evidence export permission Also fix TypeScript errors in permission.guard.spec.ts for fetch mocking. Co-Authored-By: Claude Opus 4.5 <[email protected]>
Implement assignment filtering to restrict employees/contractors to only see resources they are assigned to: - Add memberId to AuthContext for assignment checking - Create assignment-filter utility with filter builders and access checkers - Update tasks controller/service with assignment filtering on GET endpoints - Update risks controller/service with assignment filtering on GET endpoints - Add PermissionGuard and @RequirePermission to tasks and risks endpoints Employees/contractors now only see: - Tasks where they are the assignee - Risks where they are the assignee Privileged roles (owner, admin, program_manager, auditor) see all resources. Co-Authored-By: Claude Opus 4.5 <[email protected]>
Allow admins to control which departments can see specific policies: Schema changes: - Add PolicyVisibility enum (ALL, DEPARTMENT) - Add visibility and visibleToDepartments fields to Policy model API changes: - Add memberDepartment to AuthContext for visibility filtering - Create department-visibility utility with filter builders - Update policies controller to filter by visibility for restricted roles - Update policies service to accept visibility filter Policies can now be: - Visible to ALL (default) - everyone in the organization sees them - Visible to specific DEPARTMENTS only - only members in those departments see them Privileged roles (owner, admin, program_manager, auditor) see all policies regardless of visibility settings. Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Move auth server to API, app now uses proxy to forward auth requests - Remove localStorage token storage (XSS prevention) - Add rate limiting to auth proxy (60/min general, 10/min sensitive) - Add redirect URL validation to prevent open redirects - Add AUTH_SECRET validation at startup - Make all debug logging conditional on NODE_ENV - Simplify root page routing (no activeOrganizationId dependency) - Use URL-based RBAC with direct DB member lookup Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add @comp/auth package with centralized permissions and role definitions - Update API auth module to integrate with better-auth server - Add 403 responses to policy and risk endpoints for Swagger - Add assignment filter and department visibility utilities with tests - Sync permissions across app and portal - Update tsconfig and nest-cli for proper module resolution Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add dynamicAccessControl config to organization plugin - Add OrganizationRole table for storing custom roles - Configure maximum 20 roles per organization - Add schema mapping for better-auth role table Resolves: ENG-145 Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add roles module with CRUD endpoints for custom roles - Implement privilege escalation prevention - Add permission validation against valid resources/actions - Protect built-in roles (owner, admin, auditor, employee, contractor) - Add OrganizationRole table migration - Limit to 20 custom roles per organization - Require ac:create/read/update/delete permissions for role management Implements: ENG-146 Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Update roles service to accept array of roles instead of single role - Add getCombinedPermissions to merge permissions from all user roles - Update controller to pass full userRoles array - Users with multiple roles now get combined permissions for validation Co-Authored-By: Claude Opus 4.5 <[email protected]>
Add explicit jwks configuration with rotationInterval to prevent better-auth from creating new JWKS keys on each request. Without this, all existing JWTs become invalid when the API restarts because new signing keys are generated. - Set rotationInterval to 30 days for monthly key rotation - Set gracePeriod to 7 days so old keys remain valid after rotation Fixes: Session persistence across API restarts References: - better-auth/better-auth#6215 Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add 18 tests for RolesService covering CRUD operations - Add 9 tests for RolesController - Test permission validation and privilege escalation prevention - Test multiple roles support for privilege checking - Test edge cases (duplicate names, max roles limit, reserved names) Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Update .cursorrules with testing requirements and conventions - Add apps/api/CLAUDE.md with API-specific development guidelines - Document when to write tests, how to run them, and test patterns - Include RBAC system documentation Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Remove API-specific testing rules from root .cursorrules - Create apps/api/.cursorrules with API testing requirements - Keep root .cursorrules focused on commit message conventions Co-Authored-By: Claude Opus 4.5 <[email protected]>
Ensures that users cannot escalate privileges when updating role permissions, not just when creating roles. Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add roles settings pages (list, create, edit) with permission matrix - Add "Select all" feature to quickly set all permissions - Integrate custom roles into member management UI: - Role filter dropdown shows all roles dynamically - Invite modal supports custom role selection - Edit member role supports custom roles - Allow normal spelling for role names (spaces, capitalization) - Add loading skeletons with proper PageLayout wrappers - Add comprehensive tests for RolesTable, RoleForm, PermissionMatrix Co-Authored-By: Claude Opus 4.5 <[email protected]>
PR SummaryHigh Risk Overview Adds a permissions layer and wires it into endpoints. Introduces Expands org/people/policy capabilities behind RBAC. Adds org logo upload/remove (S3), role-notification settings update, API key create/revoke (now salted/hash-based), people email-preference update endpoint, new Written by Cursor Bugbot for commit 7ef29c3. This will update automatically on new commits. Configure here. |
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing validation for permission value types causes crash
Medium Severity
The validatePermissions method iterates over permission values with for (const action of actions) without checking that actions is actually an array. The DTO only validates that permissions is an object via @IsObject(), not that nested values are arrays. If a client sends { "permissions": { "control": null } }, the iteration throws a TypeError (null is not iterable), causing a 500 error instead of a proper 400 validation error.
Additional Locations (1)
| await this.vercelApi.delete( | ||
| `/v9/projects/${projectId}/domains/${domain}`, | ||
| { params: { teamId } }, | ||
| ); |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
URL
user-provided value
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 hours ago
In general, to fix this issue we should ensure that user-controlled domain is validated and normalized before being used in any outbound HTTP request, and that only syntactically valid domain names are accepted. We do not need an allow-list of concrete hostnames because the host is fixed by vercelApi’s base URL, but we should still reject invalid or hostile input (e.g., containing path separators, whitespace, or control characters) and consistently use the validated value.
The single best fix with minimal functional change is:
-
Add a small helper method in
TrustPortalServicethat:- Trims the incoming string.
- Converts it to lower case.
- Checks it against a conservative domain-name regular expression (labels of 1–63 characters, allowed
a-z0-9-, no label starting/ending with-, total length ≤ 253, and at least one dot). - Optionally strips a trailing dot if present.
- Throws
BadRequestExceptionif invalid.
-
Use this helper in
addCustomDomainto derive a sanitizednormalizedDomainfrom the incomingdomainargument and then usenormalizedDomaineverywhere in that method:- For the trust lookup (
currentTrust?.domain === normalizedDomain). - When comparing against
existingDomains. - When querying
db.trust.findUniquefor the owner. - In the Vercel delete and post URLs.
- In the
db.trust.upsertcall.
- For the trust lookup (
-
Leave the controller contract unchanged; it still accepts
{ domain: string }, but now invalid values will be rejected consistently at the service layer.
Concretely, all changes are confined to apps/api/src/trust-portal/trust-portal.service.ts in the snippet you provided. We will:
- Insert a
private normalizeAndValidateDomain(domain: string): stringmethod near theVercelDomainVerificationinterface or close toaddCustomDomain. - Update
addCustomDomainto call this helper at the top and use the resultingnormalizedDomaininstead of the rawdomain.
No changes are required in trust-portal.controller.ts, since the service will enforce the necessary validation and normalization.
-
Copy modified lines R44-R77 -
Copy modified lines R764-R766 -
Copy modified line R773 -
Copy modified line R780 -
Copy modified line R782 -
Copy modified lines R787-R789 -
Copy modified line R795 -
Copy modified line R799 -
Copy modified line R808 -
Copy modified line R815
| @@ -41,6 +41,40 @@ | ||
| reason?: string; | ||
| } | ||
|
|
||
| // Helper to normalize and validate custom domains coming from user input | ||
| // Ensures we only accept well-formed DNS hostnames and reject unexpected characters. | ||
| function normalizeAndValidateDomain(domain: string): string { | ||
| const trimmed = domain.trim().toLowerCase(); | ||
|
|
||
| if (!trimmed) { | ||
| throw new BadRequestException('Domain is required'); | ||
| } | ||
|
|
||
| // Strip a single trailing dot (FQDN form), if present | ||
| const normalized = trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed; | ||
|
|
||
| // Basic DNS hostname validation: | ||
| // - total length <= 253 | ||
| // - at least one dot | ||
| // - labels 1-63 chars, alphanumeric or hyphen, not starting/ending with hyphen | ||
| if (normalized.length === 0 || normalized.length > 253) { | ||
| throw new BadRequestException('Invalid domain name'); | ||
| } | ||
|
|
||
| if (!normalized.includes('.')) { | ||
| throw new BadRequestException('Domain must contain at least one dot'); | ||
| } | ||
|
|
||
| const labelRegex = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; | ||
| const labels = normalized.split('.'); | ||
|
|
||
| if (labels.some((label) => !labelRegex.test(label))) { | ||
| throw new BadRequestException('Invalid domain name'); | ||
| } | ||
|
|
||
| return normalized; | ||
| } | ||
|
|
||
| interface VercelDomainResponse { | ||
| name: string; | ||
| verified: boolean; | ||
| @@ -727,13 +761,16 @@ | ||
| const projectId = process.env.TRUST_PORTAL_PROJECT_ID; | ||
| const teamId = process.env.VERCEL_TEAM_ID; | ||
|
|
||
| // Normalize and validate incoming domain from user input | ||
| const normalizedDomain = normalizeAndValidateDomain(domain); | ||
|
|
||
| try { | ||
| const currentTrust = await db.trust.findUnique({ | ||
| where: { organizationId }, | ||
| }); | ||
|
|
||
| const domainVerified = | ||
| currentTrust?.domain === domain | ||
| currentTrust?.domain === normalizedDomain | ||
| ? currentTrust.domainVerified | ||
| : false; | ||
|
|
||
| @@ -746,14 +777,16 @@ | ||
| const existingDomains: Array<{ name: string }> = | ||
| existingDomainsResp.data?.domains ?? []; | ||
|
|
||
| if (existingDomains.some((d) => d.name === domain)) { | ||
| if (existingDomains.some((d) => d.name === normalizedDomain)) { | ||
| const domainOwner = await db.trust.findUnique({ | ||
| where: { organizationId, domain }, | ||
| where: { organizationId, domain: normalizedDomain }, | ||
| }); | ||
|
|
||
| if (!domainOwner || domainOwner.organizationId === organizationId) { | ||
| await this.vercelApi.delete( | ||
| `/v9/projects/${projectId}/domains/${domain}`, | ||
| `/v9/projects/${projectId}/domains/${encodeURIComponent( | ||
| normalizedDomain, | ||
| )}`, | ||
| { params: { teamId } }, | ||
| ); | ||
| } else { | ||
| @@ -764,11 +792,11 @@ | ||
| } | ||
| } | ||
|
|
||
| this.logger.log(`Adding domain to Vercel project: ${domain}`); | ||
| this.logger.log(`Adding domain to Vercel project: ${normalizedDomain}`); | ||
|
|
||
| const addResp = await this.vercelApi.post( | ||
| `/v9/projects/${projectId}/domains`, | ||
| { name: domain }, | ||
| { name: normalizedDomain }, | ||
| { params: { teamId } }, | ||
| ); | ||
|
|
||
| @@ -780,14 +805,14 @@ | ||
| await db.trust.upsert({ | ||
| where: { organizationId }, | ||
| update: { | ||
| domain, | ||
| domain: normalizedDomain, | ||
| domainVerified, | ||
| isVercelDomain, | ||
| vercelVerification, | ||
| }, | ||
| create: { | ||
| organizationId, | ||
| domain, | ||
| domain: normalizedDomain, | ||
| domainVerified: false, | ||
| isVercelDomain, | ||
| vercelVerification, |
| axios | ||
| .get(`https://networkcalc.com/api/dns/lookup/${domain}`) |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
URL
user-provided value
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 hours ago
General approach: validate and normalize the user-provided domain before using it in the outbound request, and ensure only well-formed hostnames are accepted. Reject inputs containing characters that could affect URL structure (slashes, schemes, spaces, etc.), and enforce a strict domain/hostname pattern and basic structure (at least one dot, no leading/trailing dots or hyphens, reasonable length). Use this sanitized value both in the external API call and in any derived value like rootDomain.
Best concrete fix in this code: in TrustPortalService.checkDnsRecords, add a private helper method (within the same service class) to validate and normalize the domain parameter. Call this helper at the start of checkDnsRecords to obtain a normalizedDomain and derive rootDomain from it. Then use normalizedDomain and rootDomain in all axios.get calls. The helper should enforce a conservative regex for DNS hostnames and strip any trailing dot. If validation fails, throw BadRequestException so behavior is explicit to callers and existing business logic remains intact except for rejecting bad domains that would previously have been (incorrectly) accepted.
Concretely:
- In
apps/api/src/trust-portal/trust-portal.service.ts, inside theTrustPortalServiceclass:- Add a private static method, e.g.
sanitizeDomain(domain: string): string, that:- Trims whitespace.
- Optionally strips a final dot.
- Rejects:
- Empty strings.
- Length > 253.
- Strings containing
/,\, space,@,:. - Strings that don’t match a strict domain regex like
/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.
- Throws
BadRequestException('Invalid domain')if invalid.
- Add a private static method, e.g.
- Modify
checkDnsRecords:- At the beginning, replace direct use of
domainwith:const normalizedDomain = TrustPortalService.sanitizeDomain(domain);const rootDomain = normalizedDomain.split('.').slice(-2).join('.');
- Use
normalizedDomaininstead ofdomainin the firstaxios.getURL, and keeprootDomainfor the others.
No new imports are needed;BadRequestExceptionis already imported.
- At the beginning, replace direct use of
This keeps functionality the same for valid domains, but blocks malformed or potentially dangerous inputs and satisfies the SSRF check.
-
Copy modified lines R878-R916 -
Copy modified lines R918-R919 -
Copy modified line R923
| @@ -875,12 +875,52 @@ | ||
| private static readonly VERCEL_DNS_FALLBACK_PATTERN = | ||
| /vercel-dns[^.]*\.com\.?$/i; | ||
|
|
||
| /** | ||
| * Validate and normalize a domain name input. | ||
| * Ensures the value is a well-formed hostname and does not contain | ||
| * characters that could affect URL structure. | ||
| */ | ||
| private static sanitizeDomain(domain: string): string { | ||
| if (!domain) { | ||
| throw new BadRequestException('Domain is required'); | ||
| } | ||
|
|
||
| let normalized = domain.trim(); | ||
|
|
||
| // Remove a single trailing dot (FQDN style) if present | ||
| if (normalized.endsWith('.')) { | ||
| normalized = normalized.slice(0, -1); | ||
| } | ||
|
|
||
| // Basic length check for DNS names | ||
| if (normalized.length === 0 || normalized.length > 253) { | ||
| throw new BadRequestException('Invalid domain'); | ||
| } | ||
|
|
||
| // Disallow characters that can interfere with URL parsing | ||
| if (/[\/\\\s@:]/.test(normalized)) { | ||
| throw new BadRequestException('Invalid domain'); | ||
| } | ||
|
|
||
| // Strict hostname pattern: labels separated by dots, no leading/trailing hyphens, | ||
| // and a TLD of at least 2 characters. | ||
| const domainRegex = | ||
| /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/; | ||
|
|
||
| if (!domainRegex.test(normalized)) { | ||
| throw new BadRequestException('Invalid domain'); | ||
| } | ||
|
|
||
| return normalized; | ||
| } | ||
|
|
||
| async checkDnsRecords(organizationId: string, domain: string) { | ||
| const rootDomain = domain.split('.').slice(-2).join('.'); | ||
| const normalizedDomain = TrustPortalService.sanitizeDomain(domain); | ||
| const rootDomain = normalizedDomain.split('.').slice(-2).join('.'); | ||
|
|
||
| const [cnameResp, txtResp, vercelTxtResp] = await Promise.all([ | ||
| axios | ||
| .get(`https://networkcalc.com/api/dns/lookup/${domain}`) | ||
| .get(`https://networkcalc.com/api/dns/lookup/${normalizedDomain}`) | ||
| .catch(() => null), | ||
| axios | ||
| .get( |
| axios | ||
| .get( | ||
| `https://networkcalc.com/api/dns/lookup/${rootDomain}?type=TXT`, | ||
| ) |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
URL
user-provided value
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 hours ago
In general, to mitigate SSRF in this context, we should validate and normalize the user-provided domain before using it in any outbound HTTP request, enforcing that it is a syntactically valid domain name and not an arbitrary string. Because the hostname (networkcalc.com) is already fixed, the remaining risk comes from attacker-controlled path/query values; constraining domain to a safe, canonical domain format eliminates this.
The best targeted fix here is:
-
Add a private helper method in
TrustPortalService(intrust-portal.service.ts) that:- Accepts a
domainstring. - Trims whitespace, lowercases it.
- Validates it against a conservative regular expression for valid DNS hostnames (labels of 1–63 chars, allowed characters
a-z0-9-, no label starting/ending with-, at least one dot, overall length ≤ 253). - Rejects IP addresses and malformed values.
- Throws
BadRequestExceptionif invalid.
- Accepts a
-
In
checkDnsRecords, call this helper:- Normalize/validate the incoming
domainvia the helper before using it. - Derive
rootDomainfrom the normalized domain, then optionally validaterootDomainas well with the same helper (or simpler: only accept if the normalized domain has at least two labels and the resultingrootDomainstill matches the domain regex).
- Normalize/validate the incoming
-
Use the validated
safeDomainandsafeRootDomainin the axios URLs:https://networkcalc.com/api/dns/lookup/${safeDomain}https://networkcalc.com/api/dns/lookup/${safeRootDomain}?type=TXThttps://networkcalc.com/api/dns/lookup/_vercel.${safeRootDomain}?type=TXT
This preserves existing functionality—clients still pass domain in the same way and receive the same DNS checks—while adding strong constraints so that only legitimate domain names are ever interpolated into the outbound URLs. No new external dependencies are needed; the regex and helper can be implemented inline. All changes are confined to the shown trust-portal.service.ts code.
-
Copy modified lines R878-R905 -
Copy modified lines R907-R909 -
Copy modified line R913 -
Copy modified line R917 -
Copy modified line R922
| @@ -875,21 +875,51 @@ | ||
| private static readonly VERCEL_DNS_FALLBACK_PATTERN = | ||
| /vercel-dns[^.]*\.com\.?$/i; | ||
|
|
||
| /** | ||
| * Validate and normalize a domain name to prevent SSRF and malformed DNS lookups. | ||
| * Only allows standard DNS hostnames (no IPs or arbitrary paths). | ||
| */ | ||
| private normalizeAndValidateDomain(rawDomain: string): string { | ||
| const domain = rawDomain.trim().toLowerCase(); | ||
|
|
||
| // Basic length checks for DNS names. | ||
| if (!domain || domain.length > 253) { | ||
| throw new BadRequestException('Invalid domain format.'); | ||
| } | ||
|
|
||
| // Reject obvious URL/path injections. | ||
| if (domain.includes('/') || domain.includes('\\')) { | ||
| throw new BadRequestException('Invalid domain format.'); | ||
| } | ||
|
|
||
| // Strict DNS hostname regex: labels 1–63 chars, letters/digits/hyphen, no leading/trailing hyphen. | ||
| const domainRegex = | ||
| /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; | ||
|
|
||
| if (!domainRegex.test(domain)) { | ||
| throw new BadRequestException('Invalid domain format.'); | ||
| } | ||
|
|
||
| return domain; | ||
| } | ||
|
|
||
| async checkDnsRecords(organizationId: string, domain: string) { | ||
| const rootDomain = domain.split('.').slice(-2).join('.'); | ||
| const safeDomain = this.normalizeAndValidateDomain(domain); | ||
| const rootDomain = safeDomain.split('.').slice(-2).join('.'); | ||
| const safeRootDomain = this.normalizeAndValidateDomain(rootDomain); | ||
|
|
||
| const [cnameResp, txtResp, vercelTxtResp] = await Promise.all([ | ||
| axios | ||
| .get(`https://networkcalc.com/api/dns/lookup/${domain}`) | ||
| .get(`https://networkcalc.com/api/dns/lookup/${safeDomain}`) | ||
| .catch(() => null), | ||
| axios | ||
| .get( | ||
| `https://networkcalc.com/api/dns/lookup/${rootDomain}?type=TXT`, | ||
| `https://networkcalc.com/api/dns/lookup/${safeRootDomain}?type=TXT`, | ||
| ) | ||
| .catch(() => null), | ||
| axios | ||
| .get( | ||
| `https://networkcalc.com/api/dns/lookup/_vercel.${rootDomain}?type=TXT`, | ||
| `https://networkcalc.com/api/dns/lookup/_vercel.${safeRootDomain}?type=TXT`, | ||
| ) | ||
| .catch(() => null), | ||
| ]); |
| axios | ||
| .get( | ||
| `https://networkcalc.com/api/dns/lookup/_vercel.${rootDomain}?type=TXT`, | ||
| ) |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
URL
user-provided value
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 hours ago
In general, the problem is resolved by validating and constraining the user-controlled domain input before using it to build URLs. Rather than interpolating arbitrary strings into the request path, we should ensure the value is a well‑formed domain name (no protocol, no slashes, no spaces, no path or query components, reasonable length, allowed characters) and then use that sanitized value in the call to axios.get. If the validation fails, we throw a BadRequestException and avoid making any external request.
For this codebase, the minimal, non‑breaking approach is:
- Add a small domain‑validation helper method inside
TrustPortalServicethat:- Trims whitespace.
- Rejects values containing
/,\, spaces,?,#,@, or starting with a protocol likehttp://orhttps://. - Enforces a reasonable length (e.g., 1–253 chars) and basic hostname pattern (labels of letters/digits/hyphens separated by dots, no empty labels).
- Lowercases the domain (optional, but helpful).
- At the start of
checkDnsRecords, call this helper to get a normalizeddomain, and use that in place of the raw parameter. - Compute
rootDomainfrom the validateddomain(as currently done), and use that in the two TXT lookup URLs.
All changes happen in apps/api/src/trust-portal/trust-portal.service.ts within the shown snippet. No changes are needed in the controller, and we don’t need new external dependencies; we can implement validation with simple string and regex logic.
-
Copy modified lines R878-R909 -
Copy modified lines R911-R912 -
Copy modified line R916
| @@ -875,12 +875,45 @@ | ||
| private static readonly VERCEL_DNS_FALLBACK_PATTERN = | ||
| /vercel-dns[^.]*\.com\.?$/i; | ||
|
|
||
| /** | ||
| * Validate and normalize a user-provided domain name to ensure it is safe to | ||
| * use in outbound DNS lookup requests. | ||
| */ | ||
| private validateAndNormalizeDomain(domain: string): string { | ||
| if (!domain) { | ||
| throw new BadRequestException('Domain is required'); | ||
| } | ||
|
|
||
| const trimmed = domain.trim(); | ||
|
|
||
| // Disallow obvious URL/path/query characters and schemes. | ||
| if ( | ||
| /[\/\\\s?#@]/.test(trimmed) || | ||
| /^https?:\/\//i.test(trimmed) | ||
| ) { | ||
| throw new BadRequestException('Invalid domain format'); | ||
| } | ||
|
|
||
| if (trimmed.length < 1 || trimmed.length > 253) { | ||
| throw new BadRequestException('Invalid domain length'); | ||
| } | ||
|
|
||
| // Basic hostname pattern: labels of letters, digits, or hyphens separated by dots. | ||
| const hostnamePattern = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; | ||
| if (!hostnamePattern.test(trimmed)) { | ||
| throw new BadRequestException('Invalid domain format'); | ||
| } | ||
|
|
||
| return trimmed.toLowerCase(); | ||
| } | ||
|
|
||
| async checkDnsRecords(organizationId: string, domain: string) { | ||
| const rootDomain = domain.split('.').slice(-2).join('.'); | ||
| const normalizedDomain = this.validateAndNormalizeDomain(domain); | ||
| const rootDomain = normalizedDomain.split('.').slice(-2).join('.'); | ||
|
|
||
| const [cnameResp, txtResp, vercelTxtResp] = await Promise.all([ | ||
| axios | ||
| .get(`https://networkcalc.com/api/dns/lookup/${domain}`) | ||
| .get(`https://networkcalc.com/api/dns/lookup/${normalizedDomain}`) | ||
| .catch(() => null), | ||
| axios | ||
| .get( |
| authContext.userId!, | ||
| body.comment, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-null assertion on optional userId with API key bypass
Medium Severity
The denyPolicyChanges endpoint uses authContext.userId! (non-null assertion) but userId is optional and undefined for API key authentication. Since PermissionGuard explicitly bypasses permission checks for API keys, an API key request can reach this endpoint with userId being undefined, causing the service's comment creation logic to silently fail rather than properly attributing the action.
| connect: taskIds.map((id) => ({ id })), | ||
| }, | ||
| }), | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cross-tenant entity connections bypass organization validation
High Severity
The ControlsService.create method connects policyIds, taskIds, and requirementMappings to a new control without verifying these entities belong to the same organization. Similarly, PoliciesService.mapControls connects controlIds without organization validation. A user could potentially connect their resources to entities from other organizations if they know or guess valid IDs, violating multi-tenant isolation and potentially exposing cross-tenant data.
Additional Locations (1)
| }), | ||
| ), | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Control creation with requirementMappings lacks transaction atomicity
Medium Severity
The create method performs db.control.create followed by separate db.requirementMap.create calls wrapped in Promise.all, but these are not in a database transaction. If any requirementMap.create fails (e.g., invalid requirementId or frameworkInstanceId), the control is already committed to the database with potentially partial requirement mappings, leaving data in an inconsistent state while returning an error to the caller.
| data: { logo: null }, | ||
| }); | ||
|
|
||
| return { success: true }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Organization logo operations leak orphaned S3 files
Medium Severity
The removeLogo method sets the database logo field to null but never deletes the actual file from S3, leaving orphaned files. Similarly, uploadLogo uploads a new file without first fetching and deleting the organization's existing logo. Over time, each logo change accumulates orphaned files in S3 storage. Compare this to deletePdf in policies.service.ts which properly calls attachmentsService.deletePolicyVersionPdf before clearing the reference.
Additional Locations (1)
| const session = await auth.api.getSession({ headers }); | ||
|
|
||
| if (!session?.user?.id) { | ||
| throw new UnauthorizedException('Invalid or expired session'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PlatformAdminGuard missing error handling around session resolution
Medium Severity
The PlatformAdminGuard calls auth.api.getSession({ headers }) without a try-catch block, unlike the HybridAuthGuard which properly wraps this call and converts errors to UnauthorizedException. If getSession throws an error (network failure, malformed response, etc.), it will bubble up as a 500 Internal Server Error instead of a 401 Unauthorized. The old JWT-based implementation had proper error handling that returned null on error, which was then handled as an authentication failure.
Resolved conflicts preserving RBAC branch changes: - Kept direct API calls (useApi) instead of server actions - Kept notification matrix approach (all members, matrix filters by role) - Incorporated new features from main: Trust UI redesign, vendor sub-processor field, task status notification email - Deleted server action files that were migrated to API calls Co-Authored-By: Claude Opus 4.5 <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
|
|
||
| @Get('download-all') | ||
| @UseGuards(PermissionGuard) | ||
| @RequirePermission('policy', 'read') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Download endpoint bypasses department visibility filter
Medium Severity
The downloadAllPolicies endpoint calls policiesService.downloadAllPoliciesPdf(organizationId) without applying a visibility filter, while getAllPolicies applies buildPolicyVisibilityFilter based on authContext.memberDepartment and authContext.userRoles. This allows employees and contractors to download all published policies via the PDF bundle, bypassing the department-based visibility restrictions that apply when listing policies.


What does this PR do?
Visual Demo (For contributors especially)
A visual demonstration is strongly recommended, for both the original and new change (video / image - any one).
Video Demo (if applicable):
Image Demo (if applicable):
Mandatory Tasks (DO NOT REMOVE)
How should this be tested?
Checklist
- I haven't read the contributing guide
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
Cursor Bugbot found 1 potential issue for commit 7ef29c3