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
9 changes: 9 additions & 0 deletions src/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Strip terminal escape sequences and control characters from untrusted API text
// to prevent injection (e.g. OSC 52 clipboard writes, CSI screen clears).
const ESC_SEQ_RE = /\x1b(?:[\[\]_P\^X][^\x07\x1b]*(?:\x1b\\|\x07)?|[@-_~])/g;
const C1_RE = /[\x80-\x9f]/g;
const CTRL_CHAR_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;

export function sanitizeText(input: string): string {
return input.replace(ESC_SEQ_RE, "").replace(C1_RE, "").replace(CTRL_CHAR_RE, "");
}
3 changes: 2 additions & 1 deletion src/ui/components/header-bar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Box, Text } from "@opentui/core";
import { sanitizeText } from "../../sanitize.js";
import { theme } from "../theme.js";

export function renderHeaderBar(viewTitle: string) {
Expand All @@ -17,6 +18,6 @@ export function renderHeaderBar(viewTitle: string) {
alignItems: "center",
},
Text({ content: "tuitter", fg: theme.accentStrong }),
Text({ content: viewTitle, fg: theme.textPrimary }),
Text({ content: sanitizeText(viewTitle), fg: theme.textPrimary }),
);
}
7 changes: 5 additions & 2 deletions src/ui/components/post-card.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Box, Text } from "@opentui/core";
import { sanitizeText } from "../../sanitize.js";
import type { ExpandedPost } from "../../types.js";
import { getPostPrimaryImageUrl } from "../media/post-image-preview.js";
import { theme } from "../theme.js";
Expand Down Expand Up @@ -45,7 +46,9 @@ export function renderPostCard(item: ExpandedPost, state: PostCardState = {}) {
const liked = state.liked ?? false;
const bookmarked = state.bookmarked ?? false;

const header = `${author?.name ?? "Unknown"} (@${author?.username ?? "unknown"})`;
const name = author?.name ? sanitizeText(author.name) : "Unknown";
const handle = author?.username ? sanitizeText(author.username) : "unknown";
const header = `${name} (@${handle})`;
const stamp = formatTimestamp(post.created_at);
const avatarUrl = author?.profile_image_url;
const mediaUrl = getPostPrimaryImageUrl(item);
Expand Down Expand Up @@ -103,7 +106,7 @@ export function renderPostCard(item: ExpandedPost, state: PostCardState = {}) {
stamp ? Text({ content: stamp, fg: theme.textMuted }) : null,
),
),
Text({ content: lineClamp(post.text, 500), fg: theme.textPrimary }),
Text({ content: lineClamp(sanitizeText(post.text), 500), fg: theme.textPrimary }),
mediaSummary ? Text({ content: mediaSummary, fg: theme.textMuted }) : null,
showInlineOverlay
? Box({
Expand Down
3 changes: 2 additions & 1 deletion src/ui/components/status-bar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Box, Text } from "@opentui/core";
import { sanitizeText } from "../../sanitize.js";
import { theme } from "../theme.js";

export function renderStatusBar(message: string, hints: string) {
Expand All @@ -16,7 +17,7 @@ export function renderStatusBar(message: string, hints: string) {
justifyContent: "space-between",
alignItems: "center",
},
Text({ content: message || "Ready", fg: theme.textMuted }),
Text({ content: sanitizeText(message || "Ready"), fg: theme.textMuted }),
Text({ content: hints, fg: theme.textPrimary }),
);
}
5 changes: 3 additions & 2 deletions src/ui/components/user-info.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Box, Text } from "@opentui/core";
import { sanitizeText } from "../../sanitize.js";
import type { XUser } from "../../types.js";
import { theme } from "../theme.js";

Expand Down Expand Up @@ -65,12 +66,12 @@ export function renderUserInfo(user: XUser, state: UserInfoState = {}) {
Text({ content: "[@]", fg: theme.textMuted }),
),
Text({
content: `${user.name} (@${user.username})${verified}`,
content: `${sanitizeText(user.name)} (@${sanitizeText(user.username)})${verified}`,
fg: theme.textPrimary,
}),
),
Text({
content: user.description || "No bio available.",
content: user.description ? sanitizeText(user.description) : "No bio available.",
fg: theme.textMuted,
}),
Text({
Expand Down
22 changes: 22 additions & 0 deletions src/ui/media/post-image-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import { Jimp, JimpMime } from "jimp";
import type { ExpandedPost, XMedia } from "../../types.js";
import type { ImagePreviewData } from "../components/image-preview.js";

const ALLOWED_IMAGE_HOSTS = new Set([
"pbs.twimg.com",
"abs.twimg.com",
"video.twimg.com",
"ton.twimg.com",
]);

function isAllowedImageUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && ALLOWED_IMAGE_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}

const DEFAULT_MAX_WIDTH = 40;
const DEFAULT_MAX_HEIGHT = 12;
const CELL_ASPECT_RATIO = 0.5;
Expand Down Expand Up @@ -118,6 +134,9 @@ export async function getImagePreview(

const pending = (async (): Promise<ImagePreviewData | undefined> => {
try {
if (!isAllowedImageUrl(imageUrl)) {
return undefined;
}
const image = await Jimp.read(imageUrl);
const target = fitPreviewSize(image.bitmap.width, image.bitmap.height, normalized.maxWidth, normalized.maxHeight);

Expand Down Expand Up @@ -150,6 +169,9 @@ export async function getInlineImageData(

const pending = (async (): Promise<InlineImageData | undefined> => {
try {
if (!isAllowedImageUrl(imageUrl)) {
return undefined;
}
const image = await Jimp.read(imageUrl);
const target = fitBoundingSize(
image.bitmap.width,
Expand Down