Bump to v0.12.16 — security hardening: http(s) scheme guard on link sinks, loop-stable HTML tag strip

This commit is contained in:
Jure
2026-05-16 13:59:10 +02:00
parent 61c6703513
commit db81de9007
12 changed files with 54 additions and 13 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import type { ContentSegment } from "../../lib/parsing";
import type { PodcastEpisode } from "../../types/podcast";
import { resolveFountainEpisode } from "../../lib/podcast";
import { usePodcastStore } from "../../stores/podcast";
import { safeHttpUrl } from "../../lib/utils";
export function FountainCard({ seg }: { seg: ContentSegment }) {
const [episode, setEpisode] = useState<PodcastEpisode | null>(null);
@@ -22,7 +23,7 @@ export function FountainCard({ seg }: { seg: ContentSegment }) {
// Fallback: render as a regular link
return (
<a
href={seg.value}
href={safeHttpUrl(seg.value)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
+5 -4
View File
@@ -1,5 +1,6 @@
import { ContentSegment } from "../../lib/parsing";
import { usePodcastStore } from "../../stores/podcast";
import { safeHttpUrl } from "../../lib/utils";
export function VideoBlock({ sources }: { sources: string[] }) {
if (sources.length === 0) return null;
@@ -76,7 +77,7 @@ export function AudioBlock({ sources }: { sources: string[] }) {
export function YouTubeCard({ seg }: { seg: ContentSegment }) {
return (
<a
href={seg.value}
href={safeHttpUrl(seg.value)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
@@ -98,7 +99,7 @@ export function YouTubeCard({ seg }: { seg: ContentSegment }) {
export function VimeoCard({ seg }: { seg: ContentSegment }) {
return (
<a
href={seg.value}
href={safeHttpUrl(seg.value)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
@@ -117,7 +118,7 @@ export function VimeoCard({ seg }: { seg: ContentSegment }) {
export function SpotifyCard({ seg }: { seg: ContentSegment }) {
return (
<a
href={seg.value}
href={safeHttpUrl(seg.value)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
@@ -136,7 +137,7 @@ export function SpotifyCard({ seg }: { seg: ContentSegment }) {
export function TidalCard({ seg }: { seg: ContentSegment }) {
return (
<a
href={seg.value}
href={safeHttpUrl(seg.value)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
+2 -1
View File
@@ -4,6 +4,7 @@ import { useUIStore } from "../../stores/ui";
import { useProfile } from "../../hooks/useProfile";
import { ContentSegment } from "../../lib/parsing";
import { getNDK, fetchWithTimeout } from "../../lib/nostr";
import { safeHttpUrl } from "../../lib/utils";
// Returns true if we handled the URL internally (njump.me interception).
export function tryHandleUrlInternally(url: string): boolean {
@@ -122,7 +123,7 @@ export function renderTextSegments(
elements.push(
<a
key={i}
href={seg.value}
href={safeHttpUrl(seg.value)}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/40"
+2 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import type { PodcastShow, PodcastEpisode } from "../../types/podcast";
import { getEpisodes } from "../../lib/podcast";
import { usePodcastStore } from "../../stores/podcast";
import { stripHtmlTags } from "../../lib/utils";
function SubscribeButton({ show }: { show: PodcastShow }) {
const subscribed = usePodcastStore((s) => s.isSubscribed(show.feedUrl));
@@ -87,7 +88,7 @@ export function EpisodeList({ show, onBack }: EpisodeListProps) {
</div>
{show.description && (
<div className="text-[11px] text-text-dim mt-1 line-clamp-3">
{show.description.replace(/<[^>]+>/g, "").slice(0, 200)}
{stripHtmlTags(show.description).slice(0, 200)}
</div>
)}
</div>
+2 -1
View File
@@ -1,5 +1,6 @@
import type { PodcastShow } from "../../types/podcast";
import { usePodcastStore } from "../../stores/podcast";
import { stripHtmlTags } from "../../lib/utils";
interface PodcastCardProps {
show: PodcastShow;
@@ -32,7 +33,7 @@ export function PodcastCard({ show, onClick }: PodcastCardProps) {
<div className="text-[11px] text-text-muted truncate">{show.author}</div>
{show.description && (
<div className="text-[11px] text-text-dim mt-1 line-clamp-2">
{show.description.replace(/<[^>]+>/g, "").slice(0, 120)}
{stripHtmlTags(show.description).slice(0, 120)}
</div>
)}
</button>
+29
View File
@@ -17,3 +17,32 @@ export function profileName(profile: any, fallback: string): string {
const raw = profile?.displayName || profile?.name;
return (typeof raw === "string" ? raw : null) || fallback;
}
/**
* Returns `url` only if it uses a safe http(s) scheme, otherwise "#".
* Defense-in-depth for `href` sinks: blocks `javascript:`/`data:` URIs even if
* a future change to the content parser stops scheme-constraining them upstream.
*/
export function safeHttpUrl(url: string): string {
try {
const scheme = new URL(url).protocol;
return scheme === "http:" || scheme === "https:" ? url : "#";
} catch {
return "#";
}
}
/**
* Strips HTML tags, looping until the result is stable so split/nested tags
* like `<scr<script>ipt>` cannot survive a single pass. Used to flatten
* HTML-formatted text (e.g. podcast feed descriptions) into plain text.
*/
export function stripHtmlTags(input: string): string {
let prev: string;
let s = input;
do {
prev = s;
s = s.replace(/<[^>]*>/g, "");
} while (s !== prev);
return s;
}