fix(api,frontend): isolate active share files and allowlist link schemes

This commit is contained in:
hh
2026-08-29 12:52:54 +02:00
parent 0399145791
commit c832513e5c
10 changed files with 75 additions and 16 deletions
+8 -1
View File
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse, PlainTextResponse
from utils.files import (
content_disposition,
counts_as_download,
is_active_mime,
is_inline_mime,
resolve_mime,
)
@@ -20,7 +21,12 @@ router = APIRouter(tags=["files"], route_class=DishkaRoute)
_GONE = "This link is no longer available.\n"
_MISSING = "Not found.\n"
_NO_STORE = {"Cache-Control": "private, no-store", "X-Robots-Tag": "noindex, nofollow"}
_NO_STORE = {
"Cache-Control": "private, no-store",
"X-Robots-Tag": "noindex, nofollow",
"X-Content-Type-Options": "nosniff",
}
_SANDBOX = {"Content-Security-Policy": "sandbox"}
def _client_ip(request: Request) -> str | None:
@@ -76,6 +82,7 @@ async def _serve(
media_type=mime,
headers={
**_NO_STORE,
**(_SANDBOX if is_active_mime(mime) else {}),
"Content-Disposition": content_disposition(
row["file_name"], attachment=attachment
),
+11
View File
@@ -67,6 +67,13 @@ _GENERIC_MIMES = {
_INLINE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/")
_INLINE_MIMES = {"application/pdf", "application/json"}
_ACTIVE_MIMES = {
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"text/xml",
"application/xml",
}
_PREVIEW_AGENTS = (
"telegrambot",
@@ -202,6 +209,10 @@ def is_inline_mime(mime: str | None) -> bool:
return mime in _INLINE_MIMES or mime.startswith(_INLINE_MIME_PREFIXES)
def is_active_mime(mime: str | None) -> bool:
return bool(mime) and mime.split(";", 1)[0].strip().lower() in _ACTIVE_MIMES
def content_disposition(file_name: str, *, attachment: bool) -> str:
kind = "attachment" if attachment else "inline"
ascii_name = file_name.encode("ascii", "replace").decode("ascii").replace('"', "_")
+12
View File
@@ -76,6 +76,18 @@ export function isPreviewable(kind: string, mime: string | null): boolean {
return PREVIEW_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix));
}
const ACTIVE_MIMES = new Set([
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"text/xml",
"application/xml",
]);
export function isActiveContent(mime: string | null): boolean {
return ACTIVE_MIMES.has(mime?.split(";", 1)[0].trim().toLowerCase() ?? "");
}
export type VisualKind = "image" | "video" | "other";
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
@@ -1,5 +1,6 @@
<script lang="ts">
import type { InlineButton } from "$lib/api/types";
import { safeHref } from "$lib/format/url";
interface Props {
rows: InlineButton[][];
@@ -26,7 +27,12 @@
{#each row as button, colIndex (colIndex)}
{@const key = `${rowIndex}:${colIndex}`}
{#if button.kind === "url" && button.url}
<a class="button" href={button.url} target="_blank" rel="noopener">
<a
class="button"
href={safeHref(button.url)}
target="_blank"
rel="noopener"
>
<span class="label">{button.text}</span>
<span class="corner"></span>
</a>
@@ -1,6 +1,6 @@
<script lang="ts">
import { type MediaResult, requestMediaVersion } from "$lib/api/client";
import { visualKind } from "$lib/api/media";
import { isActiveContent, visualKind } from "$lib/api/media";
import type { MediaVersion } from "$lib/api/types";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
@@ -48,7 +48,13 @@
<span class="play"><Icon name="large-play" size="1.5rem" /></span>
</a>
{:else if result.state === "ready"}
<a class="file" href={result.url} target="_blank" rel="noopener">
<a
class="file"
href={result.url}
target="_blank"
rel="noopener"
download={isActiveContent(result.mime) ? `media_${version.id}` : undefined}
>
<Icon name="document" size="1.125rem" />
<span>{version.kind}</span>
</a>
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { MessageView } from "$lib/api/types";
import MessageMedia from "$lib/components/MessageMedia.svelte";
import { safeHref } from "$lib/format/url";
interface Props {
message: MessageView;
@@ -20,7 +21,7 @@
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
</div>
{/if}
<a class="card" href={web.url} target="_blank" rel="noopener">
<a class="card" href={safeHref(web.url)} target="_blank" rel="noopener">
{#if web.site_name}
<div class="site">{web.site_name}</div>
{:else if web.display_url}
@@ -7,6 +7,7 @@
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatListDate } from "$lib/format/datetime";
import { safeHref } from "$lib/format/url";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
@@ -94,7 +95,7 @@
<span class="title">{item.web_title ?? host(item.url)}</span>
<a
class="url"
href={item.url}
href={safeHref(item.url)}
target="_blank"
rel="noopener"
onclick={(event) => event.stopPropagation()}
@@ -6,6 +6,7 @@
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { safeHref } from "$lib/format/url";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
@@ -69,7 +70,12 @@
{#if item.web_title}
<span class="title">{item.web_title}</span>
{/if}
<a class="url" href={item.url} target="_blank" rel="noopener">
<a
class="url"
href={safeHref(item.url)}
target="_blank"
rel="noopener"
>
{item.url}
</a>
{#if item.web_site_name || item.web_description}
+4 -9
View File
@@ -1,4 +1,5 @@
import type { EntityView } from "$lib/api/types";
import { safeHref } from "$lib/format/url";
export interface TextNode {
kind: "text";
@@ -61,12 +62,6 @@ export function buildEntityTree(
return buildNodes(text, sorted, 0, text.length);
}
const PROTOCOL_RE = /^[a-z]+:/i;
function ensureProtocol(url: string): string {
return PROTOCOL_RE.test(url) ? url : `https://${url}`;
}
export function nodeText(node: EntityTreeNode): string {
if (node.kind === "text") {
return node.text;
@@ -74,10 +69,10 @@ export function nodeText(node: EntityTreeNode): string {
return node.children.map(nodeText).join("");
}
export function linkHref(node: EntityNode): string {
export function linkHref(node: EntityNode): string | undefined {
const { entity } = node;
if (entity.type === "text_link" && entity.url) {
return ensureProtocol(entity.url);
return safeHref(entity.url);
}
if (entity.type === "email") {
return `mailto:${nodeText(node)}`;
@@ -85,7 +80,7 @@ export function linkHref(node: EntityNode): string {
if (entity.type === "phone_number") {
return `tel:${nodeText(node)}`;
}
return ensureProtocol(nodeText(node));
return safeHref(nodeText(node));
}
const EMOJI_RE = /\p{Extended_Pictographic}/u;
+14
View File
@@ -0,0 +1,14 @@
const SCHEME_RE = /^([a-z][a-z0-9+.-]*):/i;
const SAFE_SCHEMES = new Set(["http", "https", "tg", "ton", "mailto", "tel"]);
export function safeHref(url: string | null | undefined): string | undefined {
if (!url) {
return;
}
const trimmed = url.trim();
const scheme = SCHEME_RE.exec(trimmed)?.[1].toLowerCase();
if (scheme === undefined) {
return `https://${trimmed}`;
}
return SAFE_SCHEMES.has(scheme) ? trimmed : undefined;
}