Skip to content

Conversation

@Marfuen
Copy link
Contributor

@Marfuen Marfuen commented Feb 2, 2026

What does this PR do?

  • Fixes #XXXX (GitHub issue number)
  • Fixes COMP-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

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):

  • Show screen recordings of the issue or feature.
  • Demonstrate how to reproduce the issue, the behavior before and after the change.

Image Demo (if applicable):

  • Add side-by-side screenshots of the original and updated change.
  • Highlight any significant change(s).

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  • Are there environment variables that should be set?
  • What are the minimal test data to have?
  • What is expected (happy path) to have (input and output)?
  • Any other important info that could help to test that PR

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

Marfuen and others added 19 commits February 2, 2026 12:19
- 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]>
@cursor
Copy link

cursor bot commented Feb 2, 2026

PR Summary

High Risk
High risk because it replaces core authentication verification with better-auth session resolution and introduces permission gating across many controllers; misconfiguration could block access or weaken authorization (API keys currently bypass PermissionGuard).

Overview
Moves API auth to better-auth session + RBAC enforcement. The API now embeds better-auth (auth.server.ts) via @thallesp/nestjs-better-auth, switches HybridAuthGuard and PlatformAdminGuard from manual JWT/JWKS verification to auth.api.getSession (bearer or cookies), and enriches auth context with memberId/memberDepartment.

Adds a permissions layer and wires it into endpoints. Introduces PermissionGuard + @RequirePermission decorator (with tests) and applies them broadly across controllers (e.g., organization, people, policies, findings, attachments, browserbase, etc.), removing X-Organization-Id swagger header docs and shifting to permission-based access.

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 controls/frameworks CRUD endpoints, department-based policy visibility filtering, and policy PDF management endpoints (upload/delete/signed inline URL) plus control mapping and a policy-change denial flow that can leave an audit comment.

Written by Cursor Bugbot for commit 7ef29c3. This will update automatically on new commits. Configure here.

@vercel
Copy link

vercel bot commented Feb 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app Ready Ready Preview, Comment Feb 4, 2026 9:54pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
portal Skipped Skipped Feb 4, 2026 9:54pm

Request Review

);
}
}
}
Copy link

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)

Fix in Cursor Fix in Web

Comment on lines +755 to +758
await this.vercelApi.delete(
`/v9/projects/${projectId}/domains/${domain}`,
{ params: { teamId } },
);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

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:

  1. Add a small helper method in TrustPortalService that:

    • 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 BadRequestException if invalid.
  2. Use this helper in addCustomDomain to derive a sanitized normalizedDomain from the incoming domain argument and then use normalizedDomain everywhere in that method:

    • For the trust lookup (currentTrust?.domain === normalizedDomain).
    • When comparing against existingDomains.
    • When querying db.trust.findUnique for the owner.
    • In the Vercel delete and post URLs.
    • In the db.trust.upsert call.
  3. 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): string method near the VercelDomainVerification interface or close to addCustomDomain.
  • Update addCustomDomain to call this helper at the top and use the resulting normalizedDomain instead of the raw domain.

No changes are required in trust-portal.controller.ts, since the service will enforce the necessary validation and normalization.


Suggested changeset 1
apps/api/src/trust-portal/trust-portal.service.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts
--- a/apps/api/src/trust-portal/trust-portal.service.ts
+++ b/apps/api/src/trust-portal/trust-portal.service.ts
@@ -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,
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +882 to +883
axios
.get(`https://networkcalc.com/api/dns/lookup/${domain}`)

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

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 the TrustPortalService class:
    • 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.
  • Modify checkDnsRecords:
    • At the beginning, replace direct use of domain with:
      • const normalizedDomain = TrustPortalService.sanitizeDomain(domain);
      • const rootDomain = normalizedDomain.split('.').slice(-2).join('.');
    • Use normalizedDomain instead of domain in the first axios.get URL, and keep rootDomain for the others.
      No new imports are needed; BadRequestException is already imported.

This keeps functionality the same for valid domains, but blocks malformed or potentially dangerous inputs and satisfies the SSRF check.


Suggested changeset 1
apps/api/src/trust-portal/trust-portal.service.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts
--- a/apps/api/src/trust-portal/trust-portal.service.ts
+++ b/apps/api/src/trust-portal/trust-portal.service.ts
@@ -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(
EOF
@@ -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(
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +885 to +888
axios
.get(
`https://networkcalc.com/api/dns/lookup/${rootDomain}?type=TXT`,
)

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

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:

  1. Add a private helper method in TrustPortalService (in trust-portal.service.ts) that:

    • Accepts a domain string.
    • 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 BadRequestException if invalid.
  2. In checkDnsRecords, call this helper:

    • Normalize/validate the incoming domain via the helper before using it.
    • Derive rootDomain from the normalized domain, then optionally validate rootDomain as well with the same helper (or simpler: only accept if the normalized domain has at least two labels and the resulting rootDomain still matches the domain regex).
  3. Use the validated safeDomain and safeRootDomain in the axios URLs:

    • https://networkcalc.com/api/dns/lookup/${safeDomain}
    • https://networkcalc.com/api/dns/lookup/${safeRootDomain}?type=TXT
    • https://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.

Suggested changeset 1
apps/api/src/trust-portal/trust-portal.service.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts
--- a/apps/api/src/trust-portal/trust-portal.service.ts
+++ b/apps/api/src/trust-portal/trust-portal.service.ts
@@ -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),
     ]);
EOF
@@ -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),
]);
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +890 to +893
axios
.get(
`https://networkcalc.com/api/dns/lookup/_vercel.${rootDomain}?type=TXT`,
)

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

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:

  1. Add a small domain‑validation helper method inside TrustPortalService that:
    • Trims whitespace.
    • Rejects values containing /, \, spaces, ?, #, @, or starting with a protocol like http:// or https://.
    • 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).
  2. At the start of checkDnsRecords, call this helper to get a normalized domain, and use that in place of the raw parameter.
  3. Compute rootDomain from the validated domain (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.

Suggested changeset 1
apps/api/src/trust-portal/trust-portal.service.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts
--- a/apps/api/src/trust-portal/trust-portal.service.ts
+++ b/apps/api/src/trust-portal/trust-portal.service.ts
@@ -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(
EOF
@@ -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(
Copilot is powered by AI and may make mistakes. Always verify output.
authContext.userId!,
body.comment,
);
}
Copy link

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.

Fix in Cursor Fix in Web

connect: taskIds.map((id) => ({ id })),
},
}),
},
Copy link

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)

Fix in Cursor Fix in Web

}),
),
);
}
Copy link

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.

Fix in Cursor Fix in Web

data: { logo: null },
});

return { success: true };
Copy link

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)

Fix in Cursor Fix in Web

@vercel vercel bot temporarily deployed to Preview – app February 4, 2026 20:06 Inactive
@vercel vercel bot temporarily deployed to Preview – portal February 4, 2026 20:06 Inactive
const session = await auth.api.getSession({ headers });

if (!session?.user?.id) {
throw new UnauthorizedException('Invalid or expired session');
Copy link

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.

Fix in Cursor Fix in Web

@vercel vercel bot temporarily deployed to Preview – portal February 4, 2026 20:43 Inactive
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]>
Copy link

@cursor cursor bot left a 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')
Copy link

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.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant