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)
This commit is contained in:
parent
979f5bd767
commit
b971d45e78
7 changed files with 290 additions and 34 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
195
NozCli/Rendering/MarkdownRenderer.cs
Normal file
195
NozCli/Rendering/MarkdownRenderer.cs
Normal file
|
|
@ -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<Match, string> 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(@"(?<!\*)\*(?!\*)([^*]+)\*(?!\*)")]
|
||||
private static partial Regex ItalicRegex();
|
||||
}
|
||||
|
|
@ -35,6 +35,9 @@ public static class NotesCache
|
|||
var tmp = CachePath + ".tmp";
|
||||
await File.WriteAllTextAsync(tmp, json);
|
||||
File.Move(tmp, CachePath, overwrite: true);
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(CachePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch { /* cache write failures are non-fatal */ }
|
||||
}
|
||||
|
|
|
|||
34
NozCli/Security/SlugGuard.cs
Normal file
34
NozCli/Security/SlugGuard.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NozCli.Security;
|
||||
|
||||
/// Validates slugs before they are sent to the server or written to disk.
|
||||
public static partial class SlugGuard
|
||||
{
|
||||
// Valid slug: lowercase alphanumeric + hyphens, optionally separated by /
|
||||
// Examples: "hydroponik", "biosysteme/hydroponik", "a/b/c"
|
||||
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*(?:/[a-z0-9][a-z0-9\-]*)*$")]
|
||||
private static partial Regex ValidSlugRegex();
|
||||
|
||||
/// Returns true if the slug is safe to use (no path traversal, valid chars).
|
||||
public static bool IsValid(string slug) =>
|
||||
!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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue