Security headers and sanitizing links

This commit is contained in:
Pavlenex 2026-02-12 22:33:48 +04:00
parent b12348548d
commit 87d39749e3
10 changed files with 62 additions and 12 deletions

15
.gitignore vendored
View File

@ -2,3 +2,18 @@ node_modules
dist
.vite
*.local
# Environment files
.env
.env.*
# IDE
.idea
.vscode
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self'; manifest-src 'self'; connect-src 'self' ws:; base-uri 'self'; form-action 'none';" />
<title>BTCPay Server Directory</title>
<meta name="description" content="The definitive list of merchants, creators, and organizations empowering the circular economy with BTCPay Server." />
<meta property="og:title" content="BTCPay Server Directory" />

View File

@ -0,0 +1,4 @@
Contact: https://github.com/btcpayserver/btcpayserver/security/policy
Expires: 2027-02-12T00:00:00.000Z
Preferred-Languages: en
Policy: https://github.com/btcpayserver/btcpayserver/security/policy

View File

@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-NOsINjbGOHhhnL1zwiLd/YHQjMqa6X9gVcT3uvwiOpE=';" />
<title>BTCPay Server Directory</title>
<script>
// Redirect all 404s to the root for SPA support on GitHub Pages

View File

@ -1,3 +1,2 @@
User-agent: *
Allow: /
Sitemap: https://directory.btcpayserver.org/sitemap.xml

View File

@ -1,6 +1,7 @@
import { Github, Twitter } from "lucide-react";
import SupporterSprite from "@/components/SupporterSprite";
import { supporters } from "@/data/supporters";
import { safeUrl } from "@/lib/url";
export default function Footer() {
return (
@ -13,7 +14,7 @@ export default function Footer() {
{supporters.map((s) => (
<a
key={s.svgId}
href={s.url}
href={safeUrl(s.url)}
target="_blank"
rel="noreferrer"
title={s.name}

View File

@ -2,6 +2,7 @@ import type { Merchant } from "@/data/categories";
import { Badge } from "@/components/ui/badge";
import { ExternalLink } from "lucide-react";
import { subTypeLabels, countryFlag, hostedBtcpayCountries } from "@/data/categories";
import { safeUrl } from "@/lib/url";
interface MerchantCardProps {
merchant: Merchant;
@ -59,7 +60,7 @@ export default function MerchantCard({ merchant }: MerchantCardProps) {
{/* Action */}
<a
href={merchant.url}
href={safeUrl(merchant.url)}
target="_blank"
rel="noreferrer"
className="absolute inset-0 z-10 focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-2xl sm:rounded-3xl"

View File

@ -26,9 +26,11 @@ export function ThemeProvider({
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem(storageKey);
const valid: string[] = ["dark", "light", "system"];
return stored && valid.includes(stored) ? (stored as Theme) : defaultTheme;
})
useEffect(() => {
const root = window.document.documentElement

15
src/lib/url.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* Validates that a URL uses a safe protocol (http: or https:).
* Returns the URL unchanged if safe, or "#" as a safe fallback.
* Prevents javascript:, data:, vbscript:, and other dangerous protocol URLs.
*/
export function safeUrl(url: string): string {
try {
const parsed = new URL(url, window.location.origin);
return parsed.protocol === "https:" || parsed.protocol === "http:"
? url
: "#";
} catch {
return "#";
}
}

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import merchantsData from "@/data/merchants.json";
import type { Merchant } from "@/data/categories";
import { typeMap } from "@/data/categories";
import { typeMap, mainTypes, merchantSubTypes, hostedBtcpayCountries } from "@/data/categories";
import MerchantCard from "@/components/MerchantCard";
import DirectoryFilters from "@/components/DirectoryFilters";
import Navbar from "@/components/Navbar";
@ -30,13 +30,24 @@ function shuffle<T>(array: T[]): T[] {
}
// URL hash helpers for shareable filter state
const validTypes = new Set<string>(mainTypes);
const validSubs = new Set<string>([
...merchantSubTypes,
...Object.keys(hostedBtcpayCountries),
]);
function parseHash(): { type: string; sub: string | null; q: string } {
const params = new URLSearchParams(window.location.hash.slice(1));
return {
type: params.get("type") || "All",
sub: params.get("sub") || null,
q: params.get("q") || "",
};
const rawType = params.get("type") || "All";
const type = validTypes.has(rawType) ? rawType : "All";
const rawSub = params.get("sub") || null;
const sub = rawSub && validSubs.has(rawSub) ? rawSub : null;
const q = params.get("q") || "";
return { type, sub, q };
}
function updateHash(type: string, sub: string | null, q: string) {