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:
- Every redirect is treated as a new untrusted destination.
- 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: URL): Promise<ResponseLike> {
const response = await fetch(url, {
redirect: "manual",
signal: AbortSignal.timeout(this.timeoutMs),
headers: {
"user-agent": "CommunityPreviewBot/1.0",
accept: "text/html",
},
});
const location = response.headers.get("location") ?? undefined;
const contentType = response.headers.get("content-type") ?? undefined;
if (!response.body) {
return {
status: response.status,
location,
contentType,
body: "",
bytes: 0,
};
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > this.maxBytes) {
await reader.cancel();
return {
status: response.status,
location,
contentType,
body: "",
bytes,
};
}
chunks.push(value);
}
const body = Buffer.concat(chunks).toString("utf8");
return {
status: response.status,
location,
contentType,
body,
bytes,
};
}
}
Do not render the extracted title with innerHTML. Treat it as untrusted text and let the UI framework escape it.
Important: DNS validation alone is not complete SSRF protection
There is a gap between resolving a hostname during validation and the HTTP client resolving it again during connection. An attacker may try to change the DNS answer between those operations.
For production, run the worker in a constrained network environment with egress rules that reject loopback, link-local, private, and internal destinations at the network layer. A controlled outbound proxy is another option. Connecting directly to a previously validated IP requires careful handling of TLS certificate validation and the original hostname; do not disable certificate checks to make it work.
The application policy and the network boundary should reinforce each other.
Connect the worker to Social Messaging
Keep the integration seam small. Your application needs to react to a delivered text message, enqueue URLs, and publish preview state back to clients through your own backend data channel or application storage.
interface CommunityTextMessage {
applicationMessageId: string;
conversationId: string;
authorId: string;
originalText: string;
}
interface PreviewRepository {
createQueued(input: {
previewId: string;
messageId: string;
url: string;
}): Promise<boolean>; // false means the idempotency key already exists
appendState(
previewId: string,
event: {
state: string;
reason?: string;
title?: string;
finalUrl?: string;
},
): Promise<void>;
}
A practical workflow is:
- Deliver the member’s original message through the Tencent RTC Social Messaging integration.
- Give the message a stable application-level ID.
- Extract only explicit
http:andhttps:URLs from the original text. - Create one preview job per URL using
messageId + normalized URLas an idempotency key. - Run preview work on the backend, never in another member’s browser.
- Persist each visible transition.
- Let clients subscribe to or poll your application’s preview records.
- If a message is withdrawn or moderated, stop exposing its preview even if a worker finishes later.
The SDK adapter should translate documented message events into CommunityTextMessage. This tutorial deliberately does not invent a Tencent RTC method name: use the event and send interfaces documented for the SDK and platform version selected by your application.
For concurrent workers, make state updates conditional. For example, only let a worker write ready if the stored record is still fetching and its job generation matches. That prevents a delayed worker from restoring a preview after the associated message has been removed.
Do not generate previews from translated text
TUIChat provides on-demand text-message translation, subject to its documented content-type, language, and edition constraints. See the official TUIChat message translation documentation before enabling it.
Translation should be a reading aid, not a new source of executable URLs. Always discover preview candidates from the immutable original message—not translated output. Otherwise, a transformed URL or newly URL-shaped phrase could trigger a request the author never posted.
A useful data boundary is:
interface MessagePresentation {
originalText: string; // authoritative input for link discovery
translatedText?: string; // optional presentation only
translationTarget?: string;
}
The translation result may be displayed near a preview, but it should not modify the preview job’s URL or authority.
Failure behavior members can understand
Avoid exposing infrastructure details such as resolved IP addresses or internal exception messages. Map internal reasons to a small set of user-facing outcomes:
| Internal outcome | Member-facing behavior | Retry policy |
|---|---|---|
malformed_url |
No preview available | Do not retry |
non_public_destination:* |
Preview blocked by link policy | Do not retry automatically |
redirect_limit_exceeded |
Preview unavailable after too many redirects | Do not retry automatically |
response_too_large |
Page is too large to preview | Do not retry automatically |
content_not_previewable |
This content type has no preview | Do not retry |
dns_resolution_failed |
Preview could not be loaded | Retry with a strict limit |
request_failed |
Preview temporarily unavailable | Retry with backoff |
http_status:429 |
Preview temporarily unavailable | Retry later; avoid a tight loop |
| Message removed during fetch | Hide preview and reject stale completion | Do not restore it |
A retry should reuse the same preview record rather than creating duplicate cards. Store an attempt count and a next-attempt timestamp, and cap automatic retries.
Release verification checklist
Before enabling previews in a community, verify all of these with controlled tests:
- [ ] Loopback IPv4 and IPv6 destinations are blocked.
- [ ] Private, link-local, and non-unicast destinations are blocked.
- [ ] Every redirect target is independently parsed and resolved.
- [ ] Redirect depth is bounded.
- [ ] DNS failure is distinguishable from a policy block.
- [ ] Connection and body-read timeouts are enforced.
- [ ] The response stream is cancelled when the byte limit is crossed.
- [ ] Only approved content types are parsed.
- [ ] Extracted titles are rendered as escaped text.
- [ ] Preview workers have restricted network egress.
- [ ] Duplicate message events do not create duplicate jobs.
- [ ] Removing a message prevents a late worker from restoring its preview.
- [ ] Translated text cannot create or modify preview requests.
- [ ] A missing preview never causes the original community message to disappear.
- [ ] Logs retain operational reasons without revealing them to ordinary members.
The practical decision
A lively community does not need to choose between plain links and an unrestricted crawler. The safer middle ground is a narrowly scoped preview service with explicit policy, bounded resource use, application-owned state, and a network boundary that assumes URLs are hostile.
For a small community, disabling automatic previews may still be the correct decision. If previews materially improve browsing, build them as a security-sensitive backend feature—not as a decorative client component.
What other user-supplied metadata does your community fetch automatically today: avatars, embeds, remote images, or attachment thumbnails?
Relationship disclosure: I am connected with Tencent RTC, and I used official Tencent RTC documentation as the implementation reference for the product integration described here.
Top comments (0)