mirror of
https://github.com/hoornet/vega.git
synced 2026-06-08 14:11:55 -07:00
Bump to v0.12.16 — security hardening: http(s) scheme guard on link sinks, loop-stable HTML tag strip
This commit is contained in:
@@ -69,6 +69,13 @@ jobs:
|
||||
|
||||
> **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install.
|
||||
|
||||
### v0.12.16 — Security hardening
|
||||
|
||||
A defense-in-depth pass on link and text rendering, prompted by a CodeQL code-scanning review. No user-facing changes, and no exploitable vulnerability was found — note URLs were already scheme-constrained upstream — but the rendering sinks are now hardened directly.
|
||||
|
||||
- **External links** now route every `href` through a scheme guard that permits only `http(s)://`. A `javascript:` or `data:` URI in note content can never reach a clickable link, even if the content parser changes in future.
|
||||
- **Podcast description text** is now stripped of HTML tags with a loop-until-stable pass, so split or nested tags can't survive sanitization.
|
||||
|
||||
### v0.12.15 — Honest update banner on Linux
|
||||
|
||||
The "Update & restart" banner now adapts to how Vega was installed. On a package-manager install — the AUR `vega-nostr-git` package, or a `.deb`/`.rpm` — the in-app updater can't replace a root-owned binary under `/usr`, so the button did nothing. The banner now detects the install kind and shows the right path:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
pkgname=vega-nostr
|
||||
pkgver=0.12.15
|
||||
pkgver=0.12.16
|
||||
pkgrel=1
|
||||
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
||||
arch=('x86_64')
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vega",
|
||||
"private": true,
|
||||
"version": "0.12.15",
|
||||
"version": "0.12.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5429,7 +5429,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vega"
|
||||
version = "0.12.15"
|
||||
version = "0.12.16"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "vega"
|
||||
version = "0.12.15"
|
||||
version = "0.12.16"
|
||||
description = "Cross-platform Nostr desktop client with Lightning integration"
|
||||
authors = ["hoornet"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Vega",
|
||||
"version": "0.12.15",
|
||||
"version": "0.12.16",
|
||||
"identifier": "com.hoornet.vega",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user