A casual community prompt—share a pet photo, story, or favorite animal link—creates a surprisingly serious backend decision: should your chat server automatically fetch every URL that members post?

Automatic previews make a social space easier to browse. They also turn your infrastructure into an HTTP client controlled by untrusted input. A malicious or accidental URL could target a loopback address, a private service, an oversized document, or a public URL that redirects somewhere private.

The useful framing is not “Are link previews safe?” It is:

Which URLs may the preview worker fetch, what state should members see, and what happens when the worker cannot safely decide?

This tutorial builds that boundary in TypeScript. It is designed for a Tencent RTC Social Messaging community, but the security policy remains independent from the messaging SDK.

What we are building

A member posts this message:

This rescue organization has an adoption guide: https://example.org/adopt 

The original message can appear immediately. Preview generation proceeds asynchronously through explicit states:

queued → validating → fetching → ready ├──────→ blocked └──────→ failed 

Those terminal states mean different things:

  • ready: metadata was fetched within policy.
  • blocked: policy intentionally refused the URL, address, redirect, or content type.
  • failed: the operation was allowed but could not finish, such as a DNS or network failure.

A blocked or failed preview does not delete the member’s message. It also does not certify that the link itself is safe. It only describes the preview operation.

Tencent RTC’s Social Messaging solution covers community conversations and rich-media social experiences. We will use the messaging layer for conversation delivery while keeping URL fetching in an application-owned backend worker.

Create the reproducible project

Use Node.js with TypeScript:

mkdir community-link-preview cd community-link-preview npm init -y npm install ipaddr.js npm install --save-dev typescript tsx @types/node mkdir src test 

Update package.json:

{ "type": "module", "scripts": { "test": "tsx --test test/*.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "ipaddr.js": "^2.2.0" }, "devDependencies": { "@types/node": "^24.0.0", "tsx": "^4.0.0", "typescript": "^5.0.0" } } 

Add tsconfig.json:

{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src", "test"] } 

Exact dependency versions can be locked by your package manager. The ranges above are not Tencent RTC product requirements.

Implement the preview state machine

Create src/preview.ts:

import ipaddr from "ipaddr.js"; export type PreviewState = | "queued" | "validating" | "fetching" | "ready" | "blocked" | "failed"; export interface PreviewEvent { state: PreviewState; reason?: string; title?: string; finalUrl?: string; } export interface Resolver { resolve(hostname: string): Promise<string[]>; } export interface ResponseLike { status: number; location?: string; contentType?: string; body: string; bytes: number; } export interface Requester { get(url: URL): Promise<ResponseLike>; } export interface PreviewDependencies { resolver: Resolver; requester: Requester; maxBytes?: number; maxRedirects?: number; } class PolicyBlocked extends Error {} function parseHttpUrl(raw: string): URL { let url: URL; try { url = new URL(raw); } catch { throw new PolicyBlocked("malformed_url"); } if (url.protocol !== "http:" && url.protocol !== "https:") { throw new PolicyBlocked("unsupported_protocol"); } if (url.username || url.password) { throw new PolicyBlocked("embedded_credentials"); } if (!url.hostname) { throw new PolicyBlocked("missing_hostname"); } return url; } function normalizedHostname(url: URL): string { // URL.hostname may retain brackets for an IPv6 literal. return url.hostname.replace(/^\[/, "").replace(/\]$/, ""); } async function assertPublicDestination( url: URL, resolver: Resolver, ): Promise<void> { const hostname = normalizedHostname(url); let addresses: string[]; try { addresses = ipaddr.isValid(hostname) ? [hostname] : await resolver.resolve(hostname); } catch { throw new Error("dns_resolution_failed"); } if (addresses.length === 0) { throw new Error("dns_returned_no_addresses"); } for (const address of addresses) { if (!ipaddr.isValid(address)) { throw new PolicyBlocked("resolver_returned_invalid_address"); } const range = ipaddr.parse(address).range(); // A conservative policy accepts only globally routable unicast results. // If DNS returns a mixture of public and private addresses, reject all. if (range !== "unicast") { throw new PolicyBlocked(`non_public_destination:${range}`); } } } function extractTitle(html: string): string | undefined { const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i); if (!match) return undefined; const title = match[1].replace(/\s+/g, " ").trim(); return title ? title.slice(0, 200) : undefined; } export async function runPreview( rawUrl: string, dependencies: PreviewDependencies, ): Promise<PreviewEvent[]> { const events: PreviewEvent[] = [{ state: "queued" }]; const maxBytes = dependencies.maxBytes ?? 256 * 1024; const maxRedirects = dependencies.maxRedirects ?? 3; const emit = (event: PreviewEvent) => events.push(event); let current: URL; try { emit({ state: "validating" }); current = parseHttpUrl(rawUrl); } catch (error) { emit({ state: "blocked", reason: error instanceof Error ? error.message : "invalid_url", }); return events; } for (let redirectCount = 0; ; redirectCount += 1) { try { await assertPublicDestination(current, dependencies.resolver); } catch (error) { if (error instanceof PolicyBlocked) { emit({ state: "blocked", reason: error.message }); } else { emit({ state: "failed", reason: error instanceof Error ? error.message : "validation_failed", }); } return events; } emit({ state: "fetching", finalUrl: current.toString() }); let response: ResponseLike; try { response = await dependencies.requester.get(current); } catch { emit({ state: "failed", reason: "request_failed" }); return events; } if (response.status >= 300 && response.status < 400) { if (!response.location) { emit({ state: "failed", reason: "redirect_without_location" }); return events; } if (redirectCount >= maxRedirects) { emit({ state: "blocked", reason: "redirect_limit_exceeded" }); return events; } try { // Relative redirects are resolved against the current URL. current = parseHttpUrl(new URL(response.location, current).toString()); } catch (error) { emit({ state: "blocked", reason: error instanceof Error ? error.message : "invalid_redirect", }); return events; } // The next iteration resolves and validates the new destination. continue; } if (response.status !== 200) { emit({ state: "failed", reason: `http_status:${response.status}` }); return events; } if (response.bytes > maxBytes) { emit({ state: "blocked", reason: "response_too_large" }); return events; } const contentType = response.contentType?.toLowerCase() ?? ""; if (!contentType.startsWith("text/html")) { emit({ state: "blocked", reason: "content_not_previewable" }); return events; } emit({ state: "ready", title: extractTitle(response.body), finalUrl: current.toString(), }); return events; } } 

Two details matter here:

  1. Every redirect is treated as a new untrusted destination.
  2. If DNS returns even one private or otherwise non-public address, the job is blocked rather than selecting a more convenient result.

That second choice can reject some unusual but legitimate infrastructure. It is an intentional security-versus-compatibility trade-off.

Prove the dangerous paths without making network requests

Create test/preview.test.ts:

import assert from "node:assert/strict"; import test from "node:test"; import { runPreview, type Requester, type Resolver, } from "../src/preview.js"; const publicResolver: Resolver = { async resolve() { return ["93.184.216.34"]; }, }; test("blocks a direct loopback destination before requesting it", async () => { let requests = 0; const requester: Requester = { async get() { requests += 1; throw new Error("must not be called"); }, }; const events = await runPreview("http://127.0.0.1/admin", { resolver: publicResolver, requester, }); assert.equal(events.at(-1)?.state, "blocked"); assert.match(events.at(-1)?.reason ?? "", /non_public_destination/); assert.equal(requests, 0); }); test("revalidates a redirect instead of trusting the first host", async () => { let requests = 0; const requester: Requester = { async get() { requests += 1; return { status: 302, location: "http://127.0.0.1/internal", body: "", bytes: 0, }; }, }; const events = await runPreview("https://public.example/start", { resolver: publicResolver, requester, }); assert.equal(requests, 1); assert.equal(events.at(-1)?.state, "blocked"); }); test("returns a ready preview for allowed HTML", async () => { const requester: Requester = { async get() { const body = "<html><title>Pet Adoption Guide</title></html>"; return { status: 200, contentType: "text/html; charset=utf-8", body, bytes: Buffer.byteLength(body), }; }, }; const events = await runPreview("https://public.example/adopt", { resolver: publicResolver, requester, }); assert.deepEqual(events.at(-1), { state: "ready", title: "Pet Adoption Guide", finalUrl: "https://public.example/adopt", }); }); test("blocks an oversized response", async () => { const requester: Requester = { async get() { return { status: 200, contentType: "text/html", body: "truncated", bytes: 300_000, }; }, }; const events = await runPreview("https://public.example/large", { resolver: publicResolver, requester, maxBytes: 100_000, }); assert.equal(events.at(-1)?.state, "blocked"); assert.equal(events.at(-1)?.reason, "response_too_large"); }); 

Run the suite:

npm test npm run typecheck 

The tests verify policy without contacting a real site or depending on mutable public DNS.

The requester must stop reading at the byte limit

The test interface reports bytes, but a production requester must not download an unlimited response and check its size afterward. Enforce the limit while streaming.

A Node adapter can follow this shape:

import type { Requester, ResponseLike } from "./preview.js"; export class NodeRequester implements Requester { constructor( private readonly maxBytes = 256 * 1024, private readonly timeoutMs = 3_000, ) {} async get(url: URLSource:https://dev.to/susiewang/build-safer-link-previews-for-a-tencent-rtc-community-chat-ja7

Related posts