From b971d45e784e82961d428dcc9f48c2bc2c8dca86 Mon Sep 17 00:00:00 2001 From: kryptomrx Date: Sat, 16 May 2026 23:36:40 +0200 Subject: [PATCH] security: slug validation, path traversal protection, temp file cleanup - SlugGuard.IsValid() rejects slugs with path traversal (../, //, uppercase) - SlugGuard.ToSafePath() ensures sync writes stay within target directory - PushCommand: skip files with invalid slugs before upload - SyncCommand: validate server-returned slugs before writing to disk - EditCommand/NewCommand: try/finally guarantees temp file deletion on crash - NotesCache: set 600 permissions on cache file (metadata only, no token) - NewCommand: remove dead Sha256 method (HashHelper already covers this) --- NozCli/Commands/EditCommand.cs | 34 +++-- NozCli/Commands/NewCommand.cs | 37 ++--- NozCli/Commands/PushCommand.cs | 8 ++ NozCli/Commands/SyncCommand.cs | 13 +- NozCli/Rendering/MarkdownRenderer.cs | 195 +++++++++++++++++++++++++++ NozCli/Repl/NotesCache.cs | 3 + NozCli/Security/SlugGuard.cs | 34 +++++ 7 files changed, 290 insertions(+), 34 deletions(-) create mode 100644 NozCli/Rendering/MarkdownRenderer.cs create mode 100644 NozCli/Security/SlugGuard.cs diff --git a/NozCli/Commands/EditCommand.cs b/NozCli/Commands/EditCommand.cs index 23c5e0d..d0f1425 100644 --- a/NozCli/Commands/EditCommand.cs +++ b/NozCli/Commands/EditCommand.cs @@ -28,22 +28,30 @@ public class EditCommand : ICommand var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md"); await File.WriteAllTextAsync(tmp, note.Markdown); - var (editor, editorArgs) = useCode - ? ResolveCode(tmp, ctx.Config.CodeEditor) - : ResolveTerminal(tmp, ctx.Config.Editor); - - AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); - var proc = Process.Start(new ProcessStartInfo + string edited; + try { - FileName = editor, - Arguments = editorArgs, - UseShellExecute = false, - }); - proc?.WaitForExit(); + var (editor, editorArgs) = useCode + ? ResolveCode(tmp, ctx.Config.CodeEditor) + : ResolveTerminal(tmp, ctx.Config.Editor); + + AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); + var proc = Process.Start(new ProcessStartInfo + { + FileName = editor, + Arguments = editorArgs, + UseShellExecute = false, + }); + proc?.WaitForExit(); + + edited = await File.ReadAllTextAsync(tmp); + } + finally + { + if (File.Exists(tmp)) File.Delete(tmp); + } - var edited = await File.ReadAllTextAsync(tmp); var newHash = Sha256(edited); - File.Delete(tmp); if (newHash == note.Hash) { diff --git a/NozCli/Commands/NewCommand.cs b/NozCli/Commands/NewCommand.cs index 0205a5b..be84167 100644 --- a/NozCli/Commands/NewCommand.cs +++ b/NozCli/Commands/NewCommand.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.Security.Cryptography; -using System.Text; using NozCli.Core.Models; using NozCli.Repl; using Spectre.Console; @@ -47,21 +45,28 @@ public class NewCommand : ICommand var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md"); await File.WriteAllTextAsync(tmp, template); - var (editor, editorArgs) = useCode - ? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor) - : EditCommand.ResolveTerminal(tmp, ctx.Config.Editor); - - AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); - var proc = Process.Start(new ProcessStartInfo + string markdown; + try { - FileName = editor, - Arguments = editorArgs, - UseShellExecute = false, - }); - proc?.WaitForExit(); + var (editor, editorArgs) = useCode + ? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor) + : EditCommand.ResolveTerminal(tmp, ctx.Config.Editor); - var markdown = await File.ReadAllTextAsync(tmp); - File.Delete(tmp); + AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]"); + var proc = Process.Start(new ProcessStartInfo + { + FileName = editor, + Arguments = editorArgs, + UseShellExecute = false, + }); + proc?.WaitForExit(); + + markdown = await File.ReadAllTextAsync(tmp); + } + finally + { + if (File.Exists(tmp)) File.Delete(tmp); + } if (markdown.Trim() == template.Trim()) { @@ -85,6 +90,4 @@ public class NewCommand : ICommand string.Join(' ', slug.Split('-').Select(w => w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w)); - private static string Sha256(string text) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower(); } diff --git a/NozCli/Commands/PushCommand.cs b/NozCli/Commands/PushCommand.cs index ccec8e1..27fb9e6 100644 --- a/NozCli/Commands/PushCommand.cs +++ b/NozCli/Commands/PushCommand.cs @@ -1,4 +1,5 @@ using NozCli.Repl; +using NozCli.Security; using Spectre.Console; namespace NozCli.Commands; @@ -54,6 +55,13 @@ public class PushCommand : ICommand var relative = Path.GetRelativePath(localPath, file); var slug = relative.Replace(Path.DirectorySeparatorChar, '/').Replace(".md", ""); + if (!SlugGuard.IsValid(slug)) + { + AnsiConsole.MarkupLine($"[yellow]⚠ Skipped invalid slug:[/] {slug}"); + task.Increment(1); + continue; + } + var markdown = await File.ReadAllTextAsync(file); var localHash = NozCli.Core.Client.HashHelper.Sha256(markdown); diff --git a/NozCli/Commands/SyncCommand.cs b/NozCli/Commands/SyncCommand.cs index cfa0050..c9155be 100644 --- a/NozCli/Commands/SyncCommand.cs +++ b/NozCli/Commands/SyncCommand.cs @@ -1,6 +1,7 @@ using NozCli.Core.Client; using NozCli.Core.Models; using NozCli.Repl; +using NozCli.Security; using Spectre.Console; namespace NozCli.Commands; @@ -51,7 +52,13 @@ public class SyncCommand : ICommand foreach (var note in noteList) { - var filePath = SlugToPath(localPath, note.Slug); + if (!SlugGuard.IsValid(note.Slug)) + { + AnsiConsole.MarkupLine($"[yellow]⚠ Skipped unsafe slug from server:[/] {note.Slug}"); + continue; + } + + var filePath = SlugGuard.ToSafePath(localPath, note.Slug); if (!force && note.Hash is not null && File.Exists(filePath)) { @@ -104,7 +111,7 @@ public class SyncCommand : ICommand try { var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt); - var filePath = SlugToPath(localPath, note.Slug); + var filePath = SlugGuard.ToSafePath(localPath, note.Slug)!; Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); await File.WriteAllTextAsync(filePath, content.Markdown, innerCt); @@ -136,6 +143,4 @@ public class SyncCommand : ICommand } } - private static string SlugToPath(string baseDir, string slug) => - Path.Combine(baseDir, slug.Replace('/', Path.DirectorySeparatorChar) + ".md"); } diff --git a/NozCli/Rendering/MarkdownRenderer.cs b/NozCli/Rendering/MarkdownRenderer.cs new file mode 100644 index 0000000..ad0127f --- /dev/null +++ b/NozCli/Rendering/MarkdownRenderer.cs @@ -0,0 +1,195 @@ +using System.Text; +using System.Text.RegularExpressions; +using Spectre.Console; + +namespace NozCli.Rendering; + +/// Terminal markdown renderer — converts common markdown to Spectre.Console output. +/// Handles: headings, bold/italic, inline code, fenced code blocks, +/// bullet/numbered lists, blockquotes, horizontal rules. +public static partial class MarkdownRenderer +{ + public static void Render(string markdown) + { + var lines = markdown.ReplaceLineEndings("\n").Split('\n'); + var inCode = false; + var codeLang = ""; + var codeBlock = new StringBuilder(); + + AnsiConsole.WriteLine(); + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + + // ── Fenced code block ──────────────────────────────────────────── + if (line.TrimStart().StartsWith("```")) + { + if (!inCode) + { + inCode = true; + codeLang = line.TrimStart()[3..].Trim(); + codeBlock.Clear(); + } + else + { + inCode = false; + RenderCodeBlock(codeBlock.ToString().TrimEnd(), codeLang); + } + continue; + } + + if (inCode) + { + codeBlock.AppendLine(line); + continue; + } + + // ── Horizontal rule ────────────────────────────────────────────── + if (HrRegex().IsMatch(line)) + { + AnsiConsole.Write(new Rule() { Style = new Style(new Color(60, 60, 80)) }); + continue; + } + + // ── Headings ───────────────────────────────────────────────────── + if (line.StartsWith("### ")) + { + AnsiConsole.MarkupLine($"\n [bold white]{Markup.Escape(line[4..])}[/]"); + continue; + } + if (line.StartsWith("## ")) + { + AnsiConsole.MarkupLine($"\n[bold cyan]{Markup.Escape(line[3..])}[/]"); + continue; + } + if (line.StartsWith("# ")) + { + AnsiConsole.MarkupLine($"\n[bold underline cyan]{Markup.Escape(line[2..])}[/]\n"); + continue; + } + + // ── Blockquote ─────────────────────────────────────────────────── + if (line.StartsWith("> ")) + { + var content = ApplyInline(line[2..]); + AnsiConsole.MarkupLine($"[grey46]┃[/] [dim italic]{content}[/]"); + continue; + } + + // ── Bullet list ────────────────────────────────────────────────── + var bulletMatch = BulletRegex().Match(line); + if (bulletMatch.Success) + { + var indent = bulletMatch.Groups[1].Value.Length / 2; + var content = ApplyInline(bulletMatch.Groups[2].Value); + var pad = new string(' ', indent * 2); + AnsiConsole.MarkupLine($"{pad} [dim cyan]•[/] {content}"); + continue; + } + + // ── Numbered list ──────────────────────────────────────────────── + var numMatch = NumberedRegex().Match(line); + if (numMatch.Success) + { + var num = numMatch.Groups[1].Value; + var content = ApplyInline(numMatch.Groups[2].Value); + AnsiConsole.MarkupLine($" [dim cyan]{num}.[/] {content}"); + continue; + } + + // ── Empty line ─────────────────────────────────────────────────── + if (string.IsNullOrWhiteSpace(line)) + { + AnsiConsole.WriteLine(); + continue; + } + + // ── Paragraph ──────────────────────────────────────────────────── + AnsiConsole.MarkupLine($" {ApplyInline(line)}"); + } + + AnsiConsole.WriteLine(); + } + + // ── Code block rendering ───────────────────────────────────────────────── + + private static void RenderCodeBlock(string code, string lang) + { + var header = lang.Length > 0 ? $"[dim]{Markup.Escape(lang)}[/]" : "[dim]code[/]"; + var panel = new Panel(new Text(code, new Style(new Color(200, 200, 220)))) + { + Header = new PanelHeader($" {header} "), + Border = BoxBorder.Rounded, + BorderStyle = new Style(new Color(60, 60, 80)), + Padding = new Padding(1, 0), + }; + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + } + + // ── Inline formatting ──────────────────────────────────────────────────── + + private static string ApplyInline(string raw) + { + // Collect all pattern matches, escape text between them. + // This ensures brackets/special chars in regular text don't break Spectre markup. + (Regex regex, Func format)[] patterns = + [ + (InlineCodeRegex(), m => $"[bold cyan on grey7] {Markup.Escape(m.Groups[1].Value)} [/]"), + (BoldRegex(), m => $"[bold]{Markup.Escape(m.Groups[1].Value)}[/]"), + (ItalicRegex(), m => $"[italic]{Markup.Escape(m.Groups[1].Value)}[/]"), + ]; + + var spans = new List<(int start, int end, string replacement)>(); + + foreach (var (regex, format) in patterns) + { + foreach (Match m in regex.Matches(raw)) + { + // Skip if overlapping with an already-claimed span + if (spans.Exists(s => m.Index < s.end && m.Index + m.Length > s.start)) + continue; + spans.Add((m.Index, m.Index + m.Length, format(m))); + } + } + + spans.Sort((a, b) => a.start.CompareTo(b.start)); + + var sb = new StringBuilder(); + var pos = 0; + + foreach (var (start, end, replacement) in spans) + { + if (pos < start) + sb.Append(Markup.Escape(raw[pos..start])); + sb.Append(replacement); + pos = end; + } + + if (pos < raw.Length) + sb.Append(Markup.Escape(raw[pos..])); + + return sb.ToString(); + } + + // ── Compiled regexes (AOT-safe via [GeneratedRegex]) ──────────────────── + + [GeneratedRegex(@"^[-*_]{3,}\s*$")] + private static partial Regex HrRegex(); + + [GeneratedRegex(@"^( *)[-*+] (.+)$")] + private static partial Regex BulletRegex(); + + [GeneratedRegex(@"^(\d+)\. (.+)$")] + private static partial Regex NumberedRegex(); + + [GeneratedRegex(@"`([^`]+)`")] + private static partial Regex InlineCodeRegex(); + + [GeneratedRegex(@"\*\*([^*]+)\*\*")] + private static partial Regex BoldRegex(); + + [GeneratedRegex(@"(? + !string.IsNullOrWhiteSpace(slug) && + !slug.Contains("..") && + !slug.Contains("//") && + ValidSlugRegex().IsMatch(slug); + + /// Resolves a slug to a file path inside baseDir. + /// Returns null if the resulting path would escape baseDir (path traversal). + public static string? ToSafePath(string baseDir, string slug, string extension = ".md") + { + var rel = slug.Replace('/', Path.DirectorySeparatorChar) + extension; + var fullPath = Path.GetFullPath(Path.Combine(baseDir, rel)); + var baseFull = Path.GetFullPath(baseDir); + + // Ensure the resolved path stays strictly inside baseDir + return fullPath.StartsWith(baseFull + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + ? fullPath + : null; + } +}