diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index ef74b77..2ca1d11 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + security-events: write steps: - name: Checkout code uses: actions/checkout@v6 @@ -25,5 +26,29 @@ jobs: - name: Install dependencies run: npm ci - - name: Run ESLint + - name: Install SARIF formatter + run: npm install --no-save @microsoft/eslint-formatter-sarif + + - name: Run ESLint → SARIF + continue-on-error: true + run: npx eslint . --format @microsoft/eslint-formatter-sarif --output-file eslint-results.sarif + + # GitHub Code Scanning honoriert `suppressions[]` mit leerer Justification nicht. + # Wir filtern in-source-suppressed Results raus, damit `/* eslint-disable-next-line */` + # tatsächlich auch in Code Scanning gilt. + - name: Strip suppressed results from SARIF + if: always() + run: | + jq '.runs[].results |= map(select((.suppressions // []) | length == 0))' \ + eslint-results.sarif > eslint-filtered.sarif + mv eslint-filtered.sarif eslint-results.sarif + + - name: Upload SARIF to Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: eslint-results.sarif + category: eslint + + - name: Run ESLint (fail step on issues) run: npx eslint . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7324198..4f4304d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,27 @@ on: push: tags: - 'v*' # Trigger bei: v1.0.0, v1.2.3, etc. + workflow_dispatch: + inputs: + tag: + description: 'Tag, der released werden soll (z. B. v1.2.3)' + required: true + type: string + create_tag: + description: 'Neuen Tag erstellen, falls er noch nicht existiert' + required: false + type: boolean + default: false + ref: + description: 'Branch/Commit für den neuen Tag (nur falls oben aktiviert)' + required: false + type: string + default: main + +# Verhindert, dass sich zwei Release-Läufe für denselben Tag überschneiden +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false jobs: release: @@ -12,48 +33,93 @@ jobs: contents: write steps: + # Tag ermitteln — funktioniert sowohl bei Tag-Push als auch bei manueller Ausführung + - name: Tag ermitteln + id: tag + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF_NAME}" + fi + if [[ ! "$TAG" =~ ^v ]]; then + echo "::error::Tag '$TAG' entspricht nicht dem erwarteten Muster 'v*'." + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # Checkout des Tags (falls vorhanden) bzw. des angegebenen Branches (für neue Tags). + # fetch-depth: 0 holt komplette History + alle Tags. - name: Checkout uses: actions/checkout@v6 with: - fetch-depth: 0 # Komplette Git-History für Diff + ref: ${{ inputs.ref || github.ref }} + fetch-depth: 0 + + # Prüft, ob der Tag existiert. Falls nicht: + # - create_tag aktiviert → Tag wird aus 'ref' erstellt und gepusht + # - create_tag deaktiviert → Abbruch mit Hinweis + - name: Tag prüfen & ggf. erstellen + run: | + set -euo pipefail + TAG="${{ steps.tag.outputs.tag }}" + + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "✅ Tag '$TAG' existiert bereits." + exit 0 + fi + + if [ "${{ inputs.create_tag }}" != "true" ]; then + echo "::error::Tag '$TAG' existiert nicht. Workflow erneut ausführen und die Option 'Neuen Tag erstellen' aktivieren — der Tag wird dann aus '${{ inputs.ref }}' erstellt." + exit 1 + fi + + echo "🏷️ Tag '$TAG' existiert nicht — wird aus '${{ inputs.ref }}' erstellt." + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release $TAG" + git push origin "refs/tags/$TAG" + echo "✅ Tag '$TAG' erstellt und gepusht." - name: Modified files & Commits ermitteln - id: changes run: | - PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - CURR_TAG="${GITHUB_REF_NAME}" + set -euo pipefail + CURR_TAG="${{ steps.tag.outputs.tag }}" + PREV_TAG=$(git describe --tags --abbrev=0 "${CURR_TAG}^" 2>/dev/null || echo "") + REPO="${GITHUB_REPOSITORY}" if [ -z "$PREV_TAG" ]; then - RANGE="HEAD" - echo -e "> ⚠️ First Release – no comparison with previous tag possible.\n" > release_body.md + RANGE="$CURR_TAG" + printf '> ⚠️ First Release – no comparison with previous tag possible.\n\n' > release_body.md else RANGE="$PREV_TAG..$CURR_TAG" - echo -e "> 🔍 Comparison: [\`$PREV_TAG\` → \`$CURR_TAG\`](https://github.com/MSK-Scripts/msk-shortener/compare/$PREV_TAG...$CURR_TAG)\n" > release_body.md + printf '> 🔍 Comparison: [`%s` → `%s`](https://github.com/%s/compare/%s...%s)\n\n' \ + "$PREV_TAG" "$CURR_TAG" "$REPO" "$PREV_TAG" "$CURR_TAG" > release_body.md fi # Commits - REPO="MSK-Scripts/msk-shortener" echo "## 📝 Commits" >> release_body.md - git log $RANGE --pretty=format:"%H %s (%an)" | while read hash rest; do - SHORT=$(echo $hash | cut -c1-7) - echo "- [\`$SHORT\`](https://github.com/$REPO/commit/$hash) $rest" >> release_body.md + git log "$RANGE" --pretty=format:"%h %H %s (%an)" | while read -r short hash rest; do + echo "- [\`$short\`](https://github.com/$REPO/commit/$hash) $rest" >> release_body.md done # Modified files - echo -e "\n\n## 📂 Modified files" >> release_body.md + printf '\n\n## 📂 Modified files\n' >> release_body.md echo '```' >> release_body.md - git diff --name-status $RANGE \ + git diff --name-status "$RANGE" \ | sed 's/^A\t/✅ NEW /;s/^M\t/✏️ CHANGED /;s/^D\t/🗑️ DELETED /' \ >> release_body.md echo '```' >> release_body.md # Statistics - STATS=$(git diff --shortstat $RANGE 2>/dev/null || echo "First Release") - echo -e "\n---\n📊 **$STATS**" >> release_body.md + STATS=$(git diff --shortstat "$RANGE" 2>/dev/null || echo "First Release") + printf '\n---\n📊 **%s**\n' "$STATS" >> release_body.md - name: GitHub Release erstellen uses: softprops/action-gh-release@v3 with: + tag_name: ${{ steps.tag.outputs.tag }} body_path: release_body.md generate_release_notes: true token: ${{ secrets.GITHUB_TOKEN }} diff --git a/components/BackgroundDialog.tsx b/components/BackgroundDialog.tsx index 379241c..00c1145 100644 --- a/components/BackgroundDialog.tsx +++ b/components/BackgroundDialog.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useTransition } from 'react'; import { createPortal } from 'react-dom'; import { useBoard } from '@/store/boardStore'; import { useMounted } from '@/lib/useMounted'; +import { safeHttpUrl } from '@/lib/safeUrl'; type Props = { onClose: () => void; @@ -55,7 +56,7 @@ export function BackgroundDialog({ onClose }: Props) { if (!mounted) return null; const preview = input.trim(); - const previewValid = preview && isValidHttpUrl(preview); + const previewSrc = safeHttpUrl(preview); const save = () => { setError(null); @@ -156,13 +157,13 @@ export function BackgroundDialog({ onClose }: Props) { - {previewValid && ( + {previewSrc && (

Vorschau

{/* eslint-disable-next-line @next/next/no-img-element */} Vorschau diff --git a/components/SuggestionsForm.tsx b/components/SuggestionsForm.tsx index c4d79af..d35cd45 100644 --- a/components/SuggestionsForm.tsx +++ b/components/SuggestionsForm.tsx @@ -17,6 +17,7 @@ import { } from '@/app/(app)/integrations/discord/[guildId]/actions'; import { toast } from '@/store/toastStore'; import { confirm } from '@/store/confirmStore'; +import { safeHttpUrl } from '@/lib/safeUrl'; import { Switch } from './Switch'; import { Button } from './ui/Button'; import { TestSendButton } from './ui/TestSendButton'; @@ -911,30 +912,36 @@ function EmbedPreview({ {rendered}
- {thumbnailUrl && ( + {(() => { + const safe = safeHttpUrl(thumbnailUrl); + return safe ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + ) : null; + })()} + +
{fieldOrder.map(renderField)}
+ {(() => { + const safe = safeHttpUrl(bannerUrl); + return safe ? ( /* eslint-disable-next-line @next/next/no-img-element */ { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> - )} - -
{fieldOrder.map(renderField)}
- {bannerUrl && ( - /* eslint-disable-next-line @next/next/no-img-element */ - { - (e.currentTarget as HTMLImageElement).style.display = 'none'; - }} - /> - )} + ) : null; + })()} {ended && (
Hinweis
diff --git a/components/TicketsForm.tsx b/components/TicketsForm.tsx index d910cc2..92b89a8 100644 --- a/components/TicketsForm.tsx +++ b/components/TicketsForm.tsx @@ -23,6 +23,7 @@ import { } from '@/app/(app)/integrations/discord/[guildId]/actions'; import { toast } from '@/store/toastStore'; import { confirm } from '@/store/confirmStore'; +import { safeHttpUrl } from '@/lib/safeUrl'; import { Button } from './ui/Button'; import { ColorPicker } from './ui/ColorPicker'; import { FormRow } from './ui/FormSection'; @@ -775,10 +776,13 @@ function PanelEditor({ ))}
)} - {embedImage && ( - /* eslint-disable-next-line @next/next/no-img-element */ - - )} + {(() => { + const safe = safeHttpUrl(embedImage); + return safe ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + + ) : null; + })()} {embedFooter && (
{embedFooter}
)} diff --git a/components/WelcomeForm.tsx b/components/WelcomeForm.tsx index d8abc90..c4752a4 100644 --- a/components/WelcomeForm.tsx +++ b/components/WelcomeForm.tsx @@ -44,8 +44,19 @@ function renderPreview(template: string): string { return out; } +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Wichtig: zuerst HTML-escapen, dann Markdown-Tags einsetzen. +// Sonst kann User-Input via XSS auslösen. function renderInlineMarkdown(text: string): string { - return text + return escapeHtml(text) .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/`(.+?)`/g, '$1'); diff --git a/lib/discord.ts b/lib/discord.ts index 39f952a..08fe110 100644 --- a/lib/discord.ts +++ b/lib/discord.ts @@ -206,8 +206,29 @@ export class DiscordRateLimitError extends Error { } } +// Baut eine vollständige Discord-API-URL und verhindert Host-Wechsel durch User-Input. +// `:` (Scheme-Separator), `..` (Path-Traversal) und führendes `//` (protokoll-relativ) +// sind alle verboten — damit kann `new URL(path, base)` den Host nicht überschreiben. +const DISCORD_API_ORIGIN = 'https://discord.com'; +const DISCORD_API_BASE = 'https://discord.com/api/v10/'; + +function buildDiscordUrl(path: string): URL { + if (path.length === 0 || path[0] !== '/') { + throw new Error(`Invalid Discord path: ${path}`); + } + if (path.includes(':') || path.includes('..') || path.startsWith('//')) { + throw new Error(`Invalid Discord path (unsafe characters): ${path}`); + } + // Slice führendes `/` ab — sonst würde absolute-path den base-Pfad ersetzen. + const url = new URL(path.slice(1), DISCORD_API_BASE); + if (url.origin !== DISCORD_API_ORIGIN) { + throw new Error(`Invalid Discord path (host escape): ${path}`); + } + return url; +} + async function discordGet(path: string, token: string, tokenKind: 'Bot' | 'Bearer' = 'Bot'): Promise { - const res = await fetch(`${DISCORD_API}${path}`, { + const res = await fetch(buildDiscordUrl(path), { headers: { Authorization: `${tokenKind} ${token}` }, cache: 'no-store', }); diff --git a/lib/discordBot.ts b/lib/discordBot.ts index edbe34a..40f66b6 100644 --- a/lib/discordBot.ts +++ b/lib/discordBot.ts @@ -1,6 +1,7 @@ import 'server-only'; -const DISCORD_API = 'https://discord.com/api/v10'; +const DISCORD_API_ORIGIN = 'https://discord.com'; +const DISCORD_API_BASE = 'https://discord.com/api/v10/'; function botToken(): string { const t = process.env.DISCORD_BOT_TOKEN; @@ -8,11 +9,26 @@ function botToken(): string { return t; } +// Baut eine Discord-API-URL und verhindert Host-Wechsel durch User-Input. +function buildDiscordUrl(path: string): URL { + if (path.length === 0 || path[0] !== '/') { + throw new Error(`Invalid Discord path: ${path}`); + } + if (path.includes(':') || path.includes('..') || path.startsWith('//')) { + throw new Error(`Invalid Discord path (unsafe characters): ${path}`); + } + const url = new URL(path.slice(1), DISCORD_API_BASE); + if (url.origin !== DISCORD_API_ORIGIN) { + throw new Error(`Invalid Discord path (host escape): ${path}`); + } + return url; +} + async function call( path: string, init: { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown }, ): Promise { - return fetch(`${DISCORD_API}${path}`, { + return fetch(buildDiscordUrl(path), { method: init.method, headers: { Authorization: `Bot ${botToken()}`, diff --git a/lib/safeUrl.ts b/lib/safeUrl.ts new file mode 100644 index 0000000..3c0431e --- /dev/null +++ b/lib/safeUrl.ts @@ -0,0 +1,12 @@ +// Gibt die URL nur zurück, wenn sie absolute http(s) ist. +// Schützt , usw. vor javascript:/data:/file:-URLs aus User-Input. +export function safeHttpUrl(value: string | null | undefined): string | undefined { + if (!value) return undefined; + try { + const u = new URL(value); + if (u.protocol === 'http:' || u.protocol === 'https:') return u.toString(); + } catch { + // fällt durch + } + return undefined; +} diff --git a/package-lock.json b/package-lock.json index 8b08726..c94aaba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6419,34 +6419,6 @@ } } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -6757,10 +6729,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "dev": true, + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index ef10fc8..694df6c 100644 --- a/package.json +++ b/package.json @@ -33,5 +33,8 @@ "eslint-config-next": "16.2.6", "tailwindcss": "^4", "typescript": "^5" + }, + "overrides": { + "postcss": "^8.5.10" } }