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
2 changes: 1 addition & 1 deletion .github/workflows/trigger-api-tasks-deploy-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ jobs:
timeout-minutes: 20
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
run: bunx trigger.dev@4.0.6 deploy --env staging --log-level debug
run: bunx trigger.dev@4.4.3 deploy --env staging --log-level debug
2 changes: 1 addition & 1 deletion .github/workflows/trigger-api-tasks-deploy-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ jobs:
working-directory: ./apps/api
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
run: bunx trigger.dev@4.0.6 deploy
run: bunx trigger.dev@4.4.3 deploy
2 changes: 1 addition & 1 deletion .github/workflows/trigger-tasks-deploy-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ jobs:
VERCEL_ACCESS_TOKEN: ${{ secrets.VERCEL_ACCESS_TOKEN }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
run: bunx trigger.dev@4.0.6 deploy --env staging --log-level debug
run: bunx trigger.dev@4.4.3 deploy --env staging --log-level debug
2 changes: 1 addition & 1 deletion .github/workflows/trigger-tasks-deploy-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ jobs:
VERCEL_ACCESS_TOKEN: ${{ secrets.VERCEL_ACCESS_TOKEN }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
run: bunx trigger.dev@4.0.6 deploy
run: bunx trigger.dev@4.4.3 deploy
8 changes: 4 additions & 4 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
"@react-email/components": "^0.0.41",
"@react-email/render": "^2.0.4",
"@thallesp/nestjs-better-auth": "^2.4.0",
"@trigger.dev/build": "4.0.6",
"@trigger.dev/sdk": "4.0.6",
"@trigger.dev/build": "4.4.3",
"@trigger.dev/sdk": "4.4.3",
"@trycompai/auth": "workspace:*",
"@trycompai/company": "workspace:*",
"@trycompai/db": "1.3.22",
Expand Down Expand Up @@ -87,7 +87,7 @@
"prettier": "^3.5.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"trigger.dev": "4.0.6",
"trigger.dev": "4.4.3",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
Expand Down Expand Up @@ -125,7 +125,7 @@
"db:generate": "bun run db:getschema && bunx prisma generate",
"db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma",
"db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api",
"deploy:trigger-prod": "npx trigger.dev@4.0.6 deploy",
"deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy",
"dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"trigger dev\"",
"dev:nest": "nest start --watch",
"dev:trigger": "trigger dev",
Expand Down
135 changes: 135 additions & 0 deletions apps/api/src/trust-portal/policy-pdf-renderer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,141 @@ describe('PolicyPdfRendererService', () => {
expect(result.length).toBeGreaterThan(0);
});

it('handles emoji characters without producing garbled output', () => {
// Regression test for CS-191: flag emojis like 🇬🇧🇫🇷 were rendered as
// garbled text "Ø<ÝìØ<Ýç +þ" because Helvetica can't render emojis
const result = service.renderPoliciesPdfBuffer(
[
{
name: 'Policy with Emojis',
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '🇬🇧🇫🇷 English version available bellow',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: '🎉 Welcome to our policy 🌍',
},
],
},
],
},
},
],
'Test Org',
);

expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBeGreaterThan(0);
expect(result.subarray(0, 5).toString()).toBe('%PDF-');

// Verify the PDF text does NOT contain garbled emoji byte sequences
const pdfText = result.toString('latin1');
expect(pdfText).not.toContain('Ø<Ýì');
expect(pdfText).not.toContain('Ø<Ýç');
});

it('preserves accented characters alongside emojis', () => {
const result = service.renderPoliciesPdfBuffer(
[
{
name: 'Politique d\'Authentification',
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '🇫🇷 Résumé des règles d\'authentification café',
},
],
},
],
},
},
],
'Test Org',
);

expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBeGreaterThan(0);
});

it('handles content with only emojis', () => {
const result = service.renderPoliciesPdfBuffer(
[
{
name: 'Emoji Only',
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '🎉🌍😀🇬🇧' }],
},
],
},
},
],
'Test Org',
);

expect(result).toBeInstanceOf(Buffer);
});

it('handles emojis in headings and list items', () => {
const result = service.renderPoliciesPdfBuffer(
[
{
name: '📋 Policy Title',
content: {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: '🔒 Security Section' }],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '✅ Requirement met' },
],
},
],
},
],
},
],
},
},
],
'Test Org',
);

expect(result).toBeInstanceOf(Buffer);
expect(result.length).toBeGreaterThan(0);
});

it('applies custom primary color', () => {
const result = service.renderPoliciesPdfBuffer(
[
Expand Down
112 changes: 27 additions & 85 deletions apps/api/src/trust-portal/policy-pdf-renderer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,40 +71,34 @@ export class PolicyPdfRendererService {
return color;
}

/**
* Clean text for safe rendering with standard PDF fonts (Helvetica).
* Strips invisible chars, emojis, and maps typographic chars to ASCII.
*
* NOTE: Keep in sync with apps/app/src/lib/pdf-generator.ts cleanTextForPDF
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Emoji fix not applied to all PDF generators

Medium Severity

The emoji stripping fix (for CS-191 garbled flag emojis) was applied to cleanTextForPDF in two files with "keep in sync" notes referencing each other. However, apps/api/src/tasks/evidence-export/evidence-pdf-generator.ts contains a third cleanTextForPDF implementation that lacks the new emoji stripping logic entirely. Evidence export PDFs will still produce garbled output when content contains emojis.

Additional Locations (1)
Fix in Cursor Fix in Web

*/
private cleanTextForPDF(text: string): string {
// Strip invisible/control-ish unicode chars that commonly appear via copy/paste.
// These aren't visible in the editor, but previous logic converted unknown unicode to
// "?" which looks like random corruption in the generated PDF.
const strippedText = text
.replace(/\u00AD/g, '')
.replace(/[\u200B-\u200F]/g, '')
.replace(/[\u202A-\u202E]/g, '')
.replace(/[\u2060-\u206F]/g, '')
.replace(/\uFEFF/g, '')
.replace(/\uFFFD/g, '');
.replace(/\uFFFD/g, '')
// Strip emoji characters — standard PDF fonts cannot render them
.replace(
/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{231A}-\u{231B}\u{23E9}-\u{23F3}\u{23F8}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{FE0F}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu,
'',
);

const replacements: { [key: string]: string } = {
'\u2018': "'",
'\u2019': "'",
'\u201C': '"',
'\u201D': '"',
'\u2013': '-',
'\u2014': '-',
'\u2026': '...',
'\u2265': '>=',
'\u2264': '<=',
'\u00B0': 'deg',
'\u00A9': '(c)',
'\u00AE': '(R)',
'\u2122': 'TM',
'\u00A0': ' ',
'\u2022': '•',
'\u00B1': '+/-',
'\u00D7': 'x',
'\u00F7': '/',
'\u2192': '->',
'\u2190': '<-',
'\u2194': '<->',
'\u2018': "'", '\u2019': "'", '\u201C': '"', '\u201D': '"',
'\u2013': '-', '\u2014': '-', '\u2026': '...',
'\u2265': '>=', '\u2264': '<=', '\u00B0': 'deg',
'\u00A9': '(c)', '\u00AE': '(R)', '\u2122': 'TM',
'\u00A0': ' ', '\u2022': '•', '\u00B1': '+/-',
'\u00D7': 'x', '\u00F7': '/', '\u2192': '->',
'\u2190': '<-', '\u2194': '<->',
};

let cleanedText = strippedText;
Expand All @@ -119,67 +113,15 @@ export class PolicyPdfRendererService {
return char;
}
const fallbacks: { [key: string]: string } = {
à: 'a',
á: 'a',
â: 'a',
ã: 'a',
ä: 'a',
å: 'a',
æ: 'ae',
è: 'e',
é: 'e',
ê: 'e',
ë: 'e',
ì: 'i',
í: 'i',
î: 'i',
ï: 'i',
ò: 'o',
ó: 'o',
ô: 'o',
õ: 'o',
ö: 'o',
ø: 'o',
ù: 'u',
ú: 'u',
û: 'u',
ü: 'u',
ñ: 'n',
ç: 'c',
ß: 'ss',
ÿ: 'y',
À: 'A',
Á: 'A',
Â: 'A',
Ã: 'A',
Ä: 'A',
Å: 'A',
Æ: 'AE',
È: 'E',
É: 'E',
Ê: 'E',
Ë: 'E',
Ì: 'I',
Í: 'I',
Î: 'I',
Ï: 'I',
Ò: 'O',
Ó: 'O',
Ô: 'O',
Õ: 'O',
Ö: 'O',
Ø: 'O',
Ù: 'U',
Ú: 'U',
Û: 'U',
Ü: 'U',
Ñ: 'N',
Ç: 'C',
Ý: 'Y',
à: 'a', á: 'a', â: 'a', ã: 'a', ä: 'a', å: 'a', æ: 'ae',
è: 'e', é: 'e', ê: 'e', ë: 'e', ì: 'i', í: 'i', î: 'i', ï: 'i',
ò: 'o', ó: 'o', ô: 'o', õ: 'o', ö: 'o', ø: 'o',
ù: 'u', ú: 'u', û: 'u', ü: 'u', ñ: 'n', ç: 'c', ß: 'ss', ÿ: 'y',
À: 'A', Á: 'A', Â: 'A', Ã: 'A', Ä: 'A', Å: 'A', Æ: 'AE',
È: 'E', É: 'E', Ê: 'E', Ë: 'E', Ì: 'I', Í: 'I', Î: 'I', Ï: 'I',
Ò: 'O', Ó: 'O', Ô: 'O', Õ: 'O', Ö: 'O', Ø: 'O',
Ù: 'U', Ú: 'U', Û: 'U', Ü: 'U', Ñ: 'N', Ç: 'C', Ý: 'Y',
};
// Preserve unknown characters instead of coercing to "?".
// If a glyph isn't supported by the active PDF font, viewers may show a tofu box,
// but inserting "?" is worse because it looks like text was modified.
return fallbacks[char] ?? char;
});
}
Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/trust-portal/trust-portal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,8 +993,11 @@
});
}

const requiresVercelTxt = trustRecord?.isVercelDomain === true;
const isVerified =
isCnameVerified && isTxtVerified && isVercelTxtVerified;
isCnameVerified &&
isTxtVerified &&
(!requiresVercelTxt || isVercelTxtVerified);

if (!isVerified) {
return {
Expand All @@ -1017,6 +1020,24 @@
},
});

// Trigger Vercel to re-verify the domain so it provisions SSL and starts serving.
// Without this, Vercel doesn't know DNS has been configured and the domain stays inactive
// (previously required CS to manually click "Refresh" in Vercel dashboard).
if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) {
try {
await this.vercelApi.post(
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}/verify`,
{},
{ params: { teamId: process.env.VERCEL_TEAM_ID } },
);
Comment on lines +1028 to +1032

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

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

Copilot Autofix

AI 1 day ago

General approach: ensure that any user-controlled value used in constructing an HTTP request URL is strictly validated and normalized before use, especially when used in a path segment. We should reject invalid or malformed domains before reaching the vercelApi.post call, and rely only on a sanitized representation.

Best concrete fix here:

  1. Keep the centralized validateDomain function as the single gatekeeper for domain inputs, and ensure that it is always called before any use of domain in outbound requests.
  2. Slightly harden validateDomain to:
    • Trim whitespace.
    • Explicitly reject domains that are empty or excessively long.
    • Retain the strict hostname regex (which already prevents /, ?, #, etc.).
  3. Use the trimmed/validated value consistently in checkDnsRecords, including in the Vercel API call. This makes it clear to both humans and tools that the domain in the URL is sanitized.

We do not need to change functionality: the accepted domains remain conventional FQDNs; we only normalize whitespace and fail fast on obviously invalid input. The code changes are all in apps/api/src/trust-portal/trust-portal.service.ts, within the already shown snippets:

  • Update validateDomain (around lines 888–892) to normalize and more explicitly guard the input, and to return the sanitized domain so that callers can use that value.
  • Update checkDnsRecords(organizationId: string, domain: string) (line 902 ff.) to:
    • Call const sanitizedDomain = this.validateDomain(domain);
    • Use sanitizedDomain instead of domain when:
      • Computing rootDomain.
      • Calling axios.get for networkcalc.com.
      • Constructing the Vercel API path for this.vercelApi.post.

No new imports or external libraries are needed; we just adjust the existing methods.


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
@@ -885,10 +885,20 @@
   /** Validate domain to prevent path injection in API URLs */
   private static readonly VALID_DOMAIN_PATTERN = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
 
-  private validateDomain(domain: string): void {
-    if (!TrustPortalService.VALID_DOMAIN_PATTERN.test(domain)) {
+  /**
+   * Validates and normalizes a domain string coming from user input.
+   * Returns a trimmed, validated domain that is safe to use in URL path segments.
+   */
+  private validateDomain(domain: string): string {
+    const trimmed = domain.trim();
+    // Basic sanity checks to prevent abuse and malformed input
+    if (!trimmed || trimmed.length > 253) {
       throw new BadRequestException('Invalid domain format');
     }
+    if (!TrustPortalService.VALID_DOMAIN_PATTERN.test(trimmed)) {
+      throw new BadRequestException('Invalid domain format');
+    }
+    return trimmed;
   }
 
   /**
@@ -900,13 +908,13 @@
     /vercel-dns[^.]*\.com\.?$/i;
 
   async checkDnsRecords(organizationId: string, domain: string) {
-    this.validateDomain(domain);
+    const sanitizedDomain = this.validateDomain(domain);
 
-    const rootDomain = domain.split('.').slice(-2).join('.');
+    const rootDomain = sanitizedDomain.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/${sanitizedDomain}`)
         .catch(() => null),
       axios
         .get(
@@ -1026,14 +1030,14 @@
     if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) {
       try {
         await this.vercelApi.post(
-          `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}/verify`,
+          `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${sanitizedDomain}/verify`,
           {},
           { params: { teamId: process.env.VERCEL_TEAM_ID } },
         );
       } catch (error) {
         // Non-fatal — domain is verified on our side, Vercel will eventually pick it up
         this.logger.warn(
-          `Failed to trigger Vercel domain verification for ${domain}: ${error}`,
+          `Failed to trigger Vercel domain verification for ${sanitizedDomain}: ${error}`,
         );
       }
     }
EOF
@@ -885,10 +885,20 @@
/** Validate domain to prevent path injection in API URLs */
private static readonly VALID_DOMAIN_PATTERN = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;

private validateDomain(domain: string): void {
if (!TrustPortalService.VALID_DOMAIN_PATTERN.test(domain)) {
/**
* Validates and normalizes a domain string coming from user input.
* Returns a trimmed, validated domain that is safe to use in URL path segments.
*/
private validateDomain(domain: string): string {
const trimmed = domain.trim();
// Basic sanity checks to prevent abuse and malformed input
if (!trimmed || trimmed.length > 253) {
throw new BadRequestException('Invalid domain format');
}
if (!TrustPortalService.VALID_DOMAIN_PATTERN.test(trimmed)) {
throw new BadRequestException('Invalid domain format');
}
return trimmed;
}

/**
@@ -900,13 +908,13 @@
/vercel-dns[^.]*\.com\.?$/i;

async checkDnsRecords(organizationId: string, domain: string) {
this.validateDomain(domain);
const sanitizedDomain = this.validateDomain(domain);

const rootDomain = domain.split('.').slice(-2).join('.');
const rootDomain = sanitizedDomain.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/${sanitizedDomain}`)
.catch(() => null),
axios
.get(
@@ -1026,14 +1030,14 @@
if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) {
try {
await this.vercelApi.post(
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}/verify`,
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${sanitizedDomain}/verify`,
{},
{ params: { teamId: process.env.VERCEL_TEAM_ID } },
);
} catch (error) {
// Non-fatal — domain is verified on our side, Vercel will eventually pick it up
this.logger.warn(
`Failed to trigger Vercel domain verification for ${domain}: ${error}`,
`Failed to trigger Vercel domain verification for ${sanitizedDomain}: ${error}`,
);
}
}
Copilot is powered by AI and may make mistakes. Always verify output.
} catch (error) {
// Non-fatal — domain is verified on our side, Vercel will eventually pick it up
this.logger.warn(
`Failed to trigger Vercel domain verification for ${domain}: ${error}`,
);
}
}

return {
success: true,
isCnameVerified,
Expand Down
Loading
Loading