CVE-2022-23602: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: Nimforum is a lightweight alternative to Discourse written in Nim. In versions prior to 2.2.0 any forum user can create a new thread/post with an include referencing a file local to the host operating system. Nimforum will render the file if able. This can also be done silently by using NimForum's post "preview" endpoint. Even if NimForum is running as a non-critical user, the forum.json secrets can be stolen. Version 2.2.0 of NimForum includes patches for this vulnerability. Users are advised to upgrade as soon as is possible. There are no known workarounds for this issue.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 8.1
Published: 2022-02-01T11:15Z
Repository: https://github.com/nim-lang/Nim
Language: Other
Files changed: compiler/docgen.nim, lib/packages/docutils/rst.nim, tests/stdlib/trstgen.nim
Commit date: 2022-01-29 14:03:01 +0100
Commit message: Merge pull request from GHSA-ggrq-h43f-3w7m

This fixes a CVE (currently
https://github.com/nim-lang/Nim/security/advisories/GHSA-ggrq-h43f-3w7m)
Diff: diff --git a/compiler/docgen.nim b/compiler/docgen.nim
index 90e0f54b3..235922349 100644
--- a/compiler/docgen.nim
+++ b/compiler/docgen.nim
@@ -280,11 +280,11 @@ proc newDocumentor*(filename: AbsoluteFile; cache: IdentCache; conf: ConfigRef,
   result.module = module
   result.conf = conf
   result.cache = cache
   result.outDir = conf.outDir.string
   result.isPureRst = isPureRst
-  var options= {roSupportRawDirective, roSupportMarkdown, roPreferMarkdown}
+  var options= {roSupportRawDirective, roSupportMarkdown, roPreferMarkdown, roSandboxDisabled}
   if not isPureRst: options.incl roNimFile
   result.sharedState = newRstSharedState(
       options, filename.string,
       docgenFindFile, compilerMsgHandler)
   initRstGenerator(result[], (if conf.isLatexCmd: outLatex else: outHtml),
diff --git a/lib/packages/docutils/rst.nim b/lib/packages/docutils/rst.nim
index 791953767..93c2231dc 100644
--- a/lib/packages/docutils/rst.nim
+++ b/lib/packages/docutils/rst.nim
@@ -233,10 +233,16 @@ type
     roSupportMarkdown,        ## support additional features of Markdown
     roPreferMarkdown,         ## parse as Markdown (keeping RST as "extension"
                               ## to Markdown) -- implies `roSupportMarkdown`
     roNimFile                 ## set for Nim files where default interpreted
                               ## text role should be :nim:
+    roSandboxDisabled         ## this option enables certain options
+                              ## (e.g. raw, include)
+                              ## which are disabled by default as they can
+                              ## enable users to read arbitrary data and
+                              ## perform XSS if the parser is used in a web
+                              ## app.
 
   RstParseOptions* = set[RstParseOption]
 
   MsgClass* = enum
     mcHint = "Hint",
@@ -258,11 +264,12 @@ type
     mwUnknownSubstitution = "unknown substitution '$1'",
     mwAmbiguousLink = "ambiguous doc link $1",
     mwBrokenLink = "broken link '$1'",
     mwUnsupportedLanguage = "language '$1' not supported",
     mwUnsupportedField = "field '$1' not supported",
-    mwRstStyle = "RST style: $1"
+    mwRstStyle = "RST style: $1",
+    meSandboxedDirective = "disabled directive: '$1'",
 
   MsgHandler* = proc (filename: string, line, col: int, msgKind: MsgKind,
                        arg: string) {.closure, gcsafe.} ## what to do in case of an error
   FindFileHandler* = proc (filename: string): string {.closure, gcsafe.}
 
@@ -313,10 +320,11 @@ const
     ":-|": "icon_neutral",
     ":mrgreen:": "icon_mrgreen",
     ":geek:": "icon_e_geek",
     ":ugeek:": "icon_e_ugeek"
   }
+  SandboxDirAllowlist = ["image", "code", "code-block"]
 
 type
   TokType = enum
     tkEof, tkIndent,
     tkWhite, tkWord,
@@ -2985,14 +2993,18 @@ proc dirCodeBlock(p: var RstParser, nimExtension = false): PRstNode =
   ## ``default-language``, which is unlikely to collide with a field specified
   ## by any random rst input file.
   ##
   ## As an extension this proc will process the ``file`` extension field and if
   ## present will replace the code block with the contents of the referenced
-  ## file.
+  ## file. This behaviour is disabled in sandboxed mode and can be re-enabled
+  ## with the `roSandboxDisabled` flag.
   result = parseDirective(p, rnCodeBlock, {hasArg, hasOptions}, parseLiteralBlock)
   var filename = strip(getFieldValue(result, "file"))
   if filename != "":
+    if roSandboxDisabled notin p.s.options:
+      let tok = p.tok[p.idx-2]
+      rstMessage(p, meSandboxedDirective, "file", tok.line, tok.col)
     var path = p.findRelativeFile(filename)
     if path == "": rstMessage(p, meCannotOpenFile, filename)
     var n = newRstNode(rnLiteralBlock)
     n.add newLeaf(readFile(path))
     result.sons[2] = n
@@ -3084,10 +3096,15 @@ proc dirRaw(p: var RstParser): PRstNode =
   else:
     dirRawAux(p, result, rnRaw, parseSectionWrapper)
 
 proc selectDir(p: var RstParser, d: string): PRstNode =
   result = nil
+  let tok = p.tok[p.idx-2] # report on directive in ".. directive::"
+  if roSandboxDisabled notin p.s.options:
+    if d notin SandboxDirAllowlist:
+      rstMessage(p, meSandboxedDirective, d, tok.line, tok.col)
+
   case d
   of "admonition", "attention", "caution": result = dirAdmonition(p, d)
   of "code": result = dirCodeBlock(p)
   of "code-block": result = dirCodeBlock(p, nimExtension = true)
   of "container": result = dirContainer(p)
@@ -3110,11 +3127,10 @@ proc selectDir(p: var RstParser, d: string): PRstNode =
   of "role": result = dirRole(p)
   of "tip": result = dirAdmonition(p, d)
   of "title": result = dirTitle(p)
   of "warning": result = dirAdmonition(p, d)
   else:
-    let tok = p.tok[p.idx-2]  # report on directive in ".. directive::"
     rstMessage(p, meInvalidDirective, d, tok.line, tok.col)
 
 proc prefix(ftnType: FootnoteType): string =
   case ftnType
   of fnManualNumber: result = "footnote-"
diff --git a/tests/stdlib/trstgen.nim b/tests/stdlib/trstgen.nim
index 2be65b35b..995c0a151 100644
--- a/tests/stdlib/trstgen.nim
+++ b/tests/stdlib/trstgen.nim
@@ -45,10 +45,14 @@ proc pu(str: string): string = """<span class="Punctuation">""" & str & "</span>
 proc optionListLabel(opt: string): string =
   """<div class="option-list-label"><tt><span class="option">""" &
   opt &
   "</span></tt></div>"
 
+const
+  NoSandboxOpts = {roPreferMarkdown, roSupportMarkdown, roNimFile, roSandboxDisabled}
+
+
 suite "YAML syntax highlighting":
   test "Basics":
     let input = """.. code-block:: yaml
     %YAML 1.2
     ---
@@ -629,22 +633,27 @@ let x = 1
     let p1 = """Par1 <tt class="docutils literal"><span class="pre">""" & id"value1" & "</span></tt>."
     let p2 = """<p>Par2 <tt class="docutils literal"><span class="pre">value2</span></tt>.</p>"""
     let p3 = """<p>Par3 <tt class="docutils literal"><span class="pre">""" & id"value3" & "</span></tt>.</p>"
     let p4 = """<p>Par4 <tt class="docutils literal"><span class="pre">value4</span></tt>.</p>"""
     let expected = p1 & p2 & "\n" & p3 & "\n" & p4
-    check(input.toHtml == expected)
+    check(
+      input.toHtml(NoSandboxOpts) == expected
+    )
 
   test "role directive":
     let input = dedent"""
       .. role:: y(code)
          :language: yaml
 
       .. role:: brainhelp(code)
          :language: brainhelp
     """
     var warnings = new seq[string]
-    let output = input.toHtml(warnings=warnings)
+    let output = input.toHtml(
+      NoSandboxOpts,
+      warnings=warnings
+    )
     check(warnings[].len == 1 and "language 'brainhelp' not supported" in warnings[0])
 
   test "RST comments":
     let input1 = """
 
@@ -1185,22 +1194,26 @@ Test1
       .. important:: endOf important
       .. note:: endOf note
       .. tip:: endOf tip
       .. warning:: endOf warning
     """
-    let output0 = input0.toHtml
+    let output0 = input0.toHtml(
+      NoSandboxOpts
+    )
     for a in ["admonition", "attention", "caution", "danger", "error", "hint",
         "important", "note", "tip", "warning" ]:
       doAssert "endOf " & a & "</div>" in output0
 
     # Test that admonition does not swallow up the next paragraph.
     let input1 = dedent """
       .. error:: endOfError
 
       Test paragraph.
     """
-    let output1 = input1.toHtml
+    let output1 = input1.toHtml(
+      NoSandboxOpts
+    )
     doAssert "endOfError</div>" in output1
     doAssert "<p>Test paragraph. </p>" in output1
     doAssert "class=\"admonition admonition-error\"" in output1
 
     # Test that second line is parsed as continuation of the first line.
@@ -1208,19 +1221,23 @@ Test1
       .. error:: endOfError
         Test2p.
 
       Test paragraph.
     """
-    let output2 = input2.toHtml
+    let output2 = input2.toHtml(
+      NoSandboxOpts
+    )
     doAssert "endOfError Test2p.</div>" in output2
     doAssert "<p>Test paragraph. </p>" in output2
     doAssert "class=\"admonition admonition-error\"" in output2
 
     let input3 = dedent """
       .. note:: endOfNote
     """
-    let output3 = input3.toHtml
+    let output3 = input3.toHtml(
+      NoSandboxOpts
+    )
     doAssert "endOfNote</div>" in output3
     doAssert "class=\"admonition admonition-info\"" in output3
 
   test "RST internal links":
     let input1 = dedent """
@@ -1301,11 +1318,13 @@ Test1
 
       ----
 
       That was a transition.
     """
-    let output1 = input1.toHtml
+    let output1 = input1.toHtml(
+      NoSandboxOpts
+    )
     doAssert "<p id=\"target000\""     in output1
     doAssert "<ul id=\"target001\""    in output1
     doAssert "<ol id=\"target002\""    in output1
     doAssert "<dl id=\"target003\""    in output1
     doAssert "<p id=\"target004\""     in output1
@@ -1574,5 +1593,17 @@ suite "invalid targets":
   test "invalid links":
     check("(([Nim](https://nim-lang.org/)))".toHtml ==
         """((<a class="reference external" href="https://nim-lang.org/">Nim</a>))""")
     check("(([Nim](javascript://nim-lang.org/)))".toHtml ==
         """((<a class="reference external" href="">Nim</a>))""")
+
+suite "local file inclusion":
+  test "cannot include files in sandboxed mode":
+    var error = new string
+    discard ".. include:: ./readme.md".toHtml(error=error)
+    check(error[] == "input(1, 11) Error: disabled directive: 'include'")
+
+  test "code-block file directive is disabled":
+    var error = new string
+    discard ".. code-block:: nim\n    :file: ./readme.md".toHtml(error=error)
+    check(error[] == "input(2, 20) Error: disabled directive: 'file'")
+

Vulnerable code:   var options= {roSupportRawDirective, roSupportMarkdown, roPreferMarkdown}
    mwRstStyle = "RST style: $1"
  ## file.
    let tok = p.tok[p.idx-2]  # report on directive in ".. directive::"
    check(input.toHtml == expected)
    let output = input.toHtml(warnings=warnings)
    let output0 = input0.toHtml
    let output1 = input1.toHtml
    let output2 = input2.toHtml
    let output3 = input3.toHtml
    let output1 = input1.toHtml
Fixed code:   var options= {roSupportRawDirective, roSupportMarkdown, roPreferMarkdown, roSandboxDisabled}
    roSandboxDisabled         ## this option enables certain options
                              ## (e.g. raw, include)
                              ## which are disabled by default as they can
                              ## enable users to read arbitrary data and
                              ## perform XSS if the parser is used in a web
                              ## app.
    mwRstStyle = "RST style: $1",
    meSandboxedDirective = "disabled directive: '$1'",
  SandboxDirAllowlist = ["image", "code", "code-block"]
  ## file. This behaviour is disabled in sandboxed mode and can be re-enabled
  ## with the `roSandboxDisabled` flag.
    if roSandboxDisabled notin p.s.options:
      let tok = p.tok[p.idx-2]
      rstMessage(p, meSandboxedDirective, "file", tok.line, tok.col)
  let tok = p.tok[p.idx-2] # report on directive in ".. directive::"
  if roSandboxDisabled notin p.s.options:
    if d notin SandboxDirAllowlist:
      rstMessage(p, meSandboxedDirective, d, tok.line, tok.col)

const
  NoSandboxOpts = {roPreferMarkdown, roSupportMarkdown, roNimFile, roSandboxDisabled}


    check(
      input.toHtml(NoSandboxOpts) == expected
    )
    let output = input.toHtml(
      NoSandboxOpts,
      warnings=warnings
    )
    let output0 = input0.toHtml(
      NoSandboxOpts
    )
    let output1 = input1.toHtml(
      NoSandboxOpts
    )
    let output2 = input2.toHtml(
      NoSandboxOpts
    )
    let output3 = input3.toHtml(
      NoSandboxOpts
    )
    let output1 = input1.toHtml(
      NoSandboxOpts
    )

suite "local file inclusion":
  test "cannot include files in sandboxed mode":
    var error = new string
    discard ".. include:: ./readme.md".toHtml(error=error)
    check(error[] == "input(1, 11) Error: disabled directive: 'include'")

  test "code-block file directive is disabled":
    var error = new string
    discard ".. code-block:: nim\n    :file: ./readme.md".toHtml(error=error)
    check(error[] == "input(2, 20) Error: disabled directive: 'file'")