fix(api,frontend): isolate active share files and allowlist link schemes
This commit is contained in:
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse, PlainTextResponse
|
|||||||
from utils.files import (
|
from utils.files import (
|
||||||
content_disposition,
|
content_disposition,
|
||||||
counts_as_download,
|
counts_as_download,
|
||||||
|
is_active_mime,
|
||||||
is_inline_mime,
|
is_inline_mime,
|
||||||
resolve_mime,
|
resolve_mime,
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,12 @@ router = APIRouter(tags=["files"], route_class=DishkaRoute)
|
|||||||
_GONE = "This link is no longer available.\n"
|
_GONE = "This link is no longer available.\n"
|
||||||
_MISSING = "Not found.\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:
|
def _client_ip(request: Request) -> str | None:
|
||||||
@@ -76,6 +82,7 @@ async def _serve(
|
|||||||
media_type=mime,
|
media_type=mime,
|
||||||
headers={
|
headers={
|
||||||
**_NO_STORE,
|
**_NO_STORE,
|
||||||
|
**(_SANDBOX if is_active_mime(mime) else {}),
|
||||||
"Content-Disposition": content_disposition(
|
"Content-Disposition": content_disposition(
|
||||||
row["file_name"], attachment=attachment
|
row["file_name"], attachment=attachment
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ _GENERIC_MIMES = {
|
|||||||
|
|
||||||
_INLINE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/")
|
_INLINE_MIME_PREFIXES = ("image/", "video/", "audio/", "text/")
|
||||||
_INLINE_MIMES = {"application/pdf", "application/json"}
|
_INLINE_MIMES = {"application/pdf", "application/json"}
|
||||||
|
_ACTIVE_MIMES = {
|
||||||
|
"text/html",
|
||||||
|
"application/xhtml+xml",
|
||||||
|
"image/svg+xml",
|
||||||
|
"text/xml",
|
||||||
|
"application/xml",
|
||||||
|
}
|
||||||
|
|
||||||
_PREVIEW_AGENTS = (
|
_PREVIEW_AGENTS = (
|
||||||
"telegrambot",
|
"telegrambot",
|
||||||
@@ -202,6 +209,10 @@ def is_inline_mime(mime: str | None) -> bool:
|
|||||||
return mime in _INLINE_MIMES or mime.startswith(_INLINE_MIME_PREFIXES)
|
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:
|
def content_disposition(file_name: str, *, attachment: bool) -> str:
|
||||||
kind = "attachment" if attachment else "inline"
|
kind = "attachment" if attachment else "inline"
|
||||||
ascii_name = file_name.encode("ascii", "replace").decode("ascii").replace('"', "_")
|
ascii_name = file_name.encode("ascii", "replace").decode("ascii").replace('"', "_")
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ export function isPreviewable(kind: string, mime: string | null): boolean {
|
|||||||
return PREVIEW_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix));
|
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";
|
export type VisualKind = "image" | "video" | "other";
|
||||||
|
|
||||||
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
|
const VIDEO_KINDS = new Set(["video", "video_note", "animation", "gif"]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { InlineButton } from "$lib/api/types";
|
import type { InlineButton } from "$lib/api/types";
|
||||||
|
import { safeHref } from "$lib/format/url";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rows: InlineButton[][];
|
rows: InlineButton[][];
|
||||||
@@ -26,7 +27,12 @@
|
|||||||
{#each row as button, colIndex (colIndex)}
|
{#each row as button, colIndex (colIndex)}
|
||||||
{@const key = `${rowIndex}:${colIndex}`}
|
{@const key = `${rowIndex}:${colIndex}`}
|
||||||
{#if button.kind === "url" && button.url}
|
{#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="label">{button.text}</span>
|
||||||
<span class="corner">↗</span>
|
<span class="corner">↗</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type MediaResult, requestMediaVersion } from "$lib/api/client";
|
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 type { MediaVersion } from "$lib/api/types";
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||||
@@ -48,7 +48,13 @@
|
|||||||
<span class="play"><Icon name="large-play" size="1.5rem" /></span>
|
<span class="play"><Icon name="large-play" size="1.5rem" /></span>
|
||||||
</a>
|
</a>
|
||||||
{:else if result.state === "ready"}
|
{: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" />
|
<Icon name="document" size="1.125rem" />
|
||||||
<span>{version.kind}</span>
|
<span>{version.kind}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MessageView } from "$lib/api/types";
|
import type { MessageView } from "$lib/api/types";
|
||||||
import MessageMedia from "$lib/components/MessageMedia.svelte";
|
import MessageMedia from "$lib/components/MessageMedia.svelte";
|
||||||
|
import { safeHref } from "$lib/format/url";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
|
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if web.site_name}
|
||||||
<div class="site">{web.site_name}</div>
|
<div class="site">{web.site_name}</div>
|
||||||
{:else if web.display_url}
|
{:else if web.display_url}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||||
import { formatListDate } from "$lib/format/datetime";
|
import { formatListDate } from "$lib/format/datetime";
|
||||||
|
import { safeHref } from "$lib/format/url";
|
||||||
import { accounts } from "$lib/stores/accounts.svelte";
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
import { ui } from "$lib/stores/ui.svelte";
|
import { ui } from "$lib/stores/ui.svelte";
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
<span class="title">{item.web_title ?? host(item.url)}</span>
|
<span class="title">{item.web_title ?? host(item.url)}</span>
|
||||||
<a
|
<a
|
||||||
class="url"
|
class="url"
|
||||||
href={item.url}
|
href={safeHref(item.url)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
onclick={(event) => event.stopPropagation()}
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||||
|
import { safeHref } from "$lib/format/url";
|
||||||
import { accounts } from "$lib/stores/accounts.svelte";
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
import { ui } from "$lib/stores/ui.svelte";
|
import { ui } from "$lib/stores/ui.svelte";
|
||||||
|
|
||||||
@@ -69,7 +70,12 @@
|
|||||||
{#if item.web_title}
|
{#if item.web_title}
|
||||||
<span class="title">{item.web_title}</span>
|
<span class="title">{item.web_title}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<a class="url" href={item.url} target="_blank" rel="noopener">
|
<a
|
||||||
|
class="url"
|
||||||
|
href={safeHref(item.url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
{item.url}
|
{item.url}
|
||||||
</a>
|
</a>
|
||||||
{#if item.web_site_name || item.web_description}
|
{#if item.web_site_name || item.web_description}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { EntityView } from "$lib/api/types";
|
import type { EntityView } from "$lib/api/types";
|
||||||
|
import { safeHref } from "$lib/format/url";
|
||||||
|
|
||||||
export interface TextNode {
|
export interface TextNode {
|
||||||
kind: "text";
|
kind: "text";
|
||||||
@@ -61,12 +62,6 @@ export function buildEntityTree(
|
|||||||
return buildNodes(text, sorted, 0, text.length);
|
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 {
|
export function nodeText(node: EntityTreeNode): string {
|
||||||
if (node.kind === "text") {
|
if (node.kind === "text") {
|
||||||
return node.text;
|
return node.text;
|
||||||
@@ -74,10 +69,10 @@ export function nodeText(node: EntityTreeNode): string {
|
|||||||
return node.children.map(nodeText).join("");
|
return node.children.map(nodeText).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function linkHref(node: EntityNode): string {
|
export function linkHref(node: EntityNode): string | undefined {
|
||||||
const { entity } = node;
|
const { entity } = node;
|
||||||
if (entity.type === "text_link" && entity.url) {
|
if (entity.type === "text_link" && entity.url) {
|
||||||
return ensureProtocol(entity.url);
|
return safeHref(entity.url);
|
||||||
}
|
}
|
||||||
if (entity.type === "email") {
|
if (entity.type === "email") {
|
||||||
return `mailto:${nodeText(node)}`;
|
return `mailto:${nodeText(node)}`;
|
||||||
@@ -85,7 +80,7 @@ export function linkHref(node: EntityNode): string {
|
|||||||
if (entity.type === "phone_number") {
|
if (entity.type === "phone_number") {
|
||||||
return `tel:${nodeText(node)}`;
|
return `tel:${nodeText(node)}`;
|
||||||
}
|
}
|
||||||
return ensureProtocol(nodeText(node));
|
return safeHref(nodeText(node));
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMOJI_RE = /\p{Extended_Pictographic}/u;
|
const EMOJI_RE = /\p{Extended_Pictographic}/u;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user