Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion .github/workflows/eslint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v6
Expand All @@ -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 .
96 changes: 81 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
7 changes: 4 additions & 3 deletions components/BackgroundDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -156,13 +157,13 @@ export function BackgroundDialog({ onClose }: Props) {
</div>
</div>

{previewValid && (
{previewSrc && (
<div>
<p className="text-[11px] text-muted mb-1.5">Vorschau</p>
<div className="aspect-video rounded-md overflow-hidden border border-line-strong">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
src={previewSrc}
alt="Vorschau"
className="h-full w-full object-cover"
/>
Expand Down
41 changes: 24 additions & 17 deletions components/SuggestionsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -911,30 +912,36 @@ function EmbedPreview({
{rendered}
</div>
</div>
{thumbnailUrl && (
{(() => {
const safe = safeHttpUrl(thumbnailUrl);
return safe ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={safe}
alt=""
className="h-12 w-12 rounded object-cover shrink-0"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
) : null;
})()}
</div>
<div className="space-y-1.5">{fieldOrder.map(renderField)}</div>
{(() => {
const safe = safeHttpUrl(bannerUrl);
return safe ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={thumbnailUrl}
src={safe}
alt=""
className="h-12 w-12 rounded object-cover shrink-0"
className="rounded-md w-full max-h-60 object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
</div>
<div className="space-y-1.5">{fieldOrder.map(renderField)}</div>
{bannerUrl && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={bannerUrl}
alt=""
className="rounded-md w-full max-h-60 object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
) : null;
})()}
{ended && (
<div className="pt-1.5 border-t border-white/5">
<div className="text-[11px] uppercase tracking-wide text-[#b5bac1]">Hinweis</div>
Expand Down
12 changes: 8 additions & 4 deletions components/TicketsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -775,10 +776,13 @@ function PanelEditor({
))}
</div>
)}
{embedImage && (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={embedImage} alt="" className="max-h-48 rounded mt-1" />
)}
{(() => {
const safe = safeHttpUrl(embedImage);
return safe ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={safe} alt="" className="max-h-48 rounded mt-1" />
) : null;
})()}
{embedFooter && (
<div className="text-[10.5px] text-subtle mt-2">{embedFooter}</div>
)}
Expand Down
13 changes: 12 additions & 1 deletion components/WelcomeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,19 @@ function renderPreview(template: string): string {
return out;
}

function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

// Wichtig: zuerst HTML-escapen, dann Markdown-Tags einsetzen.
// Sonst kann User-Input via <img onerror=…> XSS auslösen.
function renderInlineMarkdown(text: string): string {
return text
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code class="rounded bg-elev px-1 text-[0.85em]">$1</code>');
Expand Down
23 changes: 22 additions & 1 deletion lib/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,32 @@
}
}

// 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<T>(path: string, token: string, tokenKind: 'Bot' | 'Bearer' = 'Bot'): Promise<T> {
const res = await fetch(`${DISCORD_API}${path}`, {
const res = await fetch(buildDiscordUrl(path), {
headers: { Authorization: `${tokenKind} ${token}` },
cache: 'no-store',
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
if (res.status === 429) {
const retryAfter = Number(res.headers.get('retry-after') ?? '5');
throw new DiscordRateLimitError(Number.isFinite(retryAfter) ? retryAfter : 5, path);
Expand Down
20 changes: 18 additions & 2 deletions lib/discordBot.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
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;
if (!t) throw new Error('DISCORD_BOT_TOKEN fehlt in .env.local');
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<Response> {
return fetch(`${DISCORD_API}${path}`, {
return fetch(buildDiscordUrl(path), {
method: init.method,
headers: {
Authorization: `Bot ${botToken()}`,
'Content-Type': 'application/json',
},
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
The
URL
of this request depends on a
user-provided value
.
}

export type EmbedPayload = {
Expand Down
12 changes: 12 additions & 0 deletions lib/safeUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Gibt die URL nur zurück, wenn sie absolute http(s) ist.
// Schützt <img src>, <a href> 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;
}
Loading
Loading