Skip to content
Open
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
36 changes: 36 additions & 0 deletions apps/api/v2/src/platform/calendars/input/create-ics.input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { validate } from "class-validator";
import { CreateIcsFeedInputDto } from "./create-ics.input";

describe("CreateIcsFeedInputDto Validation", () => {
it("should accept a standard .ics URL", async () => {
const input = new CreateIcsFeedInputDto();
input.urls = ["https://example.com/calendar.ics"];
const errors = await validate(input);
expect(errors.length).toBe(0);
});

it("should accept valid ICS URLs without a .ics extension (Fixes #29286)", async () => {
const input = new CreateIcsFeedInputDto();
input.urls = ["https://caldav.soverin.net/calendars/MyCalendar?export"];
const errors = await validate(input);
expect(errors.length).toBe(0);
});

it("should reject plain strings and malformed URLs", async () => {
const input = new CreateIcsFeedInputDto();
input.urls = ["not-a-valid-url"];
const errors = await validate(input);
expect(errors.length).toBeGreaterThan(0);
});

it("should reject localhost and local IPs to prevent SSRF", async () => {
const input = new CreateIcsFeedInputDto();
input.urls = [
"http://localhost:3000/fake-calendar",
"http://127.0.0.1/cal",
"http://192.168.1.1/feed"
];
const errors = await validate(input);
expect(errors.length).toBeGreaterThan(0);
});
});
82 changes: 66 additions & 16 deletions apps/api/v2/src/platform/calendars/input/create-ics.input.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,84 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { URL } from "node:url";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
ArrayNotEmpty,
IsBoolean,
IsOptional,
Validate,
ValidatorConstraint,
ValidatorConstraintInterface,
type ValidatorConstraintInterface, // Type-only import for clean compilation
isURL,
IsNotEmpty,
IsArray,
} from "class-validator";
import { IsNotEmpty, IsArray } from "class-validator";

// Custom constraint to validate ICS URLs
@ValidatorConstraint({ async: false })
// Custom constraint to validate ICS URLs with DNS resolution protection
@ValidatorConstraint({ async: true }) // 👈 MUST be true for async DNS lookups
export class IsICSUrlConstraint implements ValidatorConstraintInterface {
validate(url: unknown) {

private isPrivateIp(ip: string): boolean {
const ipv4Private =
/^(10\.)|^(127\.)|^(192\.168\.)|^(169\.254\.)|^(172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
const ipv6Private =
/^(::1$)|^(fe80:)|^(fc|fd)|^(::ffff:127\.)|^(::)$/i;

return ipv4Private.test(ip) || ipv6Private.test(ip);
}

private async assertSafeDns(hostname: string): Promise<void> {
const result = await lookup(hostname, { all: true });
for (const entry of result) {
if (this.isPrivateIp(entry.address)) {
throw new Error("Blocked private/internal IP resolution");
}
}
}

async validate(url: unknown): Promise<boolean> { // 👈 Changed to async Promise
if (typeof url !== "string") return false;

// Check if it's a valid URL and ends with .ics
let parsed: URL;
try {
const urlObject = new URL(url);
return (
(urlObject.protocol === "http:" || urlObject.protocol === "https:") &&
urlObject.pathname.endsWith(".ics")
);
} catch (error) {
parsed = new URL(url);
} catch {
return false;
}

const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");

if (hostname === "localhost") return false;
if (!["http:", "https:"].includes(parsed.protocol)) return false;

// Syntax check
if (
!isURL(url, {
require_protocol: true,
require_valid_protocol: true,
protocols: ["http", "https"],
require_tld: process.env.NODE_ENV === "production",
})
) {
return false;
}

// Advanced SSRF Hardening (IP & DNS checks)
if (isIP(hostname) !== 0) {
if (this.isPrivateIp(hostname)) return false;
} else {
try {
await this.assertSafeDns(hostname);
} catch {
return false;
}
}

return true;
}

defaultMessage() {
return "The URL must be a valid ICS URL (ending with .ics)";
return "Invalid or unsafe calendar URL (blocked for security reasons)";
}
}

Expand All @@ -46,16 +96,16 @@ export class CreateIcsFeedInputDto {
@IsArray()
@ArrayNotEmpty()
@IsNotEmpty({ each: true })
@Validate(IsICSUrlConstraint, { each: true }) // Apply the custom validator to each element in the array
@Validate(IsICSUrlConstraint, { each: true })
urls!: string[];

@IsBoolean()
@ApiPropertyOptional({
example: false,
description: "Whether to allowing writing to the calendar or not",
description: "Whether to allow writing to the calendar or not",
type: "boolean",
default: true,
})
@IsOptional()
readOnly?: boolean = true;
}
}
75 changes: 75 additions & 0 deletions apps/api/v2/src/platform/calendars/validators/is-calendar-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import {
ValidatorConstraint,
ValidatorConstraintInterface,
isURL,
} from "class-validator";
import { URL } from "node:url";

@ValidatorConstraint({ async: true })
export class IsICSUrlConstraint implements ValidatorConstraintInterface {
private isPrivateIp(ip: string): boolean {
return (
/^127\./.test(ip) ||
/^10\./.test(ip) ||
/^192\.168\./.test(ip) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) ||
/^169\.254\./.test(ip) ||
/^(::1|fe80:|fc|fd|::)$/i.test(ip)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: IPv6 regex does not block private/link-local addresses.

The regex /^(::1|fe80:|fc|fd|::)$/i requires an exact match due to the $ anchor being outside the alternation group. Real IPv6 addresses like fe80::1 (link-local) or fd00::1 (unique-local) will not match, allowing SSRF bypass via IPv6 private addresses.

  • fe80::1234:5678.match(regex) → null (should block, doesn't)
  • fd12:3456::1.match(regex) → null (should block, doesn't)
Proposed fix
   private isPrivateIp(ip: string): boolean {
+    const normalizedIp = ip.toLowerCase();
     return (
       /^127\./.test(ip) ||
       /^10\./.test(ip) ||
       /^192\.168\./.test(ip) ||
       /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) ||
       /^169\.254\./.test(ip) ||
-      /^(::1|fe80:|fc|fd|::)$/i.test(ip)
+      normalizedIp === "::1" ||
+      normalizedIp === "::" ||
+      /^fe80:/i.test(ip) ||
+      /^f[cd]/i.test(ip)
     );
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/v2/src/platform/calendars/validators/is-calendar-url.ts` at line 19,
The IPv6 regex in is-calendar-url.ts currently uses an alternation that only
matches exact strings (so prefixes like "fe80::1" or "fd00::1" slip through);
update the regex used in that validator to match IPv6 private/link-local
prefixes rather than exact full-string values — specifically change the pattern
referenced in is-calendar-url.ts so it anchors the start (^) and checks for
prefixes like "::1", "fe80:", and the unique-local ranges starting with "fc" or
"fd" (matching the prefix rather than requiring an exact match) and ensure
case-insensitive flag remains; replace the existing /^(::1|fe80:|fc|fd|::)$/i
pattern with a prefix-aware alternative and run tests for addresses like fe80::1
and fd00::1 to confirm they are blocked.

);
}

private async assertSafe(hostname: string) {
const records = await lookup(hostname, { all: true });

for (const r of records) {
if (this.isPrivateIp(r.address)) {
throw new Error("Private IP blocked");
}
}
}

async validate(url: unknown): Promise<boolean> {
if (typeof url !== "string") return false;

let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}

const hostname = parsed.hostname;

if (!["http:", "https:"].includes(parsed.protocol)) return false;

if (
!isURL(url, {
require_protocol: true,
require_valid_protocol: true,
})
) {
return false;
}

// IP direct check
if (isIP(hostname)) {
if (this.isPrivateIp(hostname)) return false;
return true;
}

// DNS SSRF check (THIS fixes your failing test)
try {
await this.assertSafe(hostname);
} catch {
return false;
}

return true;
}

defaultMessage() {
return "Invalid or unsafe ICS URL";
}
}
Loading