CVE-2021-41088 (Unknown, published 2021-09-23T20:15Z)
Description: value: Elvish is a programming language and interactive shell, combined into one package. In versions prior to 0.14.0 Elvish's web UI backend (started by `elvish -web`) hosts an endpoint that allows executing the code sent from the web UI. The backend does not check the origin of requests correctly. As a result, if the user has the web UI backend open and visits a compromised or malicious website, the website can send arbitrary code to the endpoint in localhost. All Elvish releases from 0.14.0 onward no longer include the the web UI, although it is still possible for the user to build a version from source that includes the web UI. The issue can be patched for previous versions by removing the web UI (found in web, pkg/web or pkg/prog/web, depending on the exact version).
Severity: HIGH (CVSS v3 base score: 8.8)
Weakness: CWE-346 \u2014 Origin Validation Error
The product does not properly verify that the source of data or communication is valid.
Repository: https://github.com/elves/elvish
Files changed:
Commit message: Remove the experimental web UI.

It has not been actively developed for a long time and has a vulnerability:
https://github.com/elves/elvish/security/advisories/GHSA-fpv6-f8jw-rc3r


Vulnerable code:
// Command elvish is an alternative main program of Elvish that includes the web
// subprogram.
package main

import (
	"os"

	"src.elv.sh/pkg/buildinfo"
	"src.elv.sh/pkg/daemon"
	"src.elv.sh/pkg/daemon/client"
	"src.elv.sh/pkg/prog"
	"src.elv.sh/pkg/shell"
	"src.elv.sh/pkg/web"
)

func main() {
	os.Exit(prog.Run(
		[3]*os.File{os.Stdin, os.Stdout, os.Stderr}, os.Args,
		prog.Composite(
			buildinfo.Program, daemon.Program, web.Program,
			shell.Program{ActivateDaemon: client.Activate})))
}
package web

const mainPageHTML = `<html>

  <body class="light">
    <div id="content">
      <div id="scrollback">
        <div id="progress"></div>
      </div>

      <div id="cmd" class="cmd">
        <span id="prompt" class="prompt">&gt;&gt;</span>
        <textarea id="buffer" class="buffer" rows="1"></textarea>
      </div>

      <div id="theme-switchers" class="flex">
        <span class="theme-switcher" id="dark-theme">dark</span>
        <span class="theme-switcher" id="light-theme">light</span>
      </div>

    </div>
  </body>

  <style>
    /* Colors are taken from Material palette. */

    /* Global styles */

    * {
      margin: 0;
      padding: 0;
      font: 12pt monospace;
    }

    #content {
      margin: 20px;
      padding: 20px;
    }

    /* Scrollback */

    .exception {
      font-weight: bold;
    }

    .server-error {
      font-style: italic;
    }

    /* Command line */

    .cmd {
      display: flex;
      width: 100%;
    }

    .cmd > .prompt {
      display: inline-block;
      margin-right: 1em;
    }

    .cmd > .buffer {
      flex: 1;
    }

    /* Theme switcher */

    #theme-switchers {
      display: flex;
      margin-top: 0.4em;
    }

    .theme-switcher {
      cursor: pointer;
      padding: 0 1em;
      border: 1px solid;
    }

    #light-theme {
      color: black;
      background-color: white;
      border-color: black;
    }

    #dark-theme {
      color: white;
      background-color: black;
      border-color: white;
    }

    /* Color schemes. Color values from Material palette. */

    body.light {
      background: #EEEEEE; /* grey 200 */
    }

    .light * {
      color: black;
      background: white;
    }

    .light #content {
      background: white;
    }

    .light .cmd > .prompt {
      color: #1565C0; /* blue 800 */
    }

    .light .cmd > #prompt {
      color: #2E7D32; /* green 800 */
    }

    .light .error {
      color: #C62828; /* red 800 */
    }


    .dark * {
      color: white;
      background: black;
    }

    body.dark {
      background: #212121; /* grey 900 */
    }

    .dark #content {
      background: black;
    }

    .dark .cmd > .prompt {
      color: #90CAF9; /* blue 200 */
    }

    .dark .cmd > #prompt {
      color: #A5D6A7; /* green 200 */
    }

    .dark .error {
      color: #EF9A9A; /* red 200 */
    }

  </style>

  <script>
    // TODO(xiaq): Stream results.
    var $prompt = document.getElementById('prompt'),
        $buffer = document.getElementById('buffer'),
        $scrollback = document.getElementById('scrollback'),
        $progress = document.getElementById('progress');

    /* Theme switchers. */
    document.getElementById('dark-theme').onclick = function() {
      document.body.className = 'dark';
    };

    document.getElementById('light-theme').onclick = function() {
      document.body.className = 'light';
    };

    $buffer.addEventListener('keypress', function(e) {
      if (e.keyCode == 13 &&
          !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
        e.preventDefault();
        execute();
      }
    });

    function execute() {
      var code = $buffer.value;
      addToScrollbackInner(freezeCmd());
      $buffer.value = '';
      $progress.innerText = 'executing...';

      var req = new XMLHttpRequest();
      req.onloadend = function() {
        $progress.innerText = '';
      };
      req.onload = function() {
        var res = JSON.parse(req.responseText);
        addToScrollback('output', res.OutBytes);
        if (res.OutValues) {
          for (var v of res.OutValues) {
            addToScrollback('output-value', v);
          }
        }
        addToScrollback('error', res.ErrBytes);
        addToScrollback('error exception', res.Err);
      };
      req.onerror = function() {
        addToScrollback('error server-error', req.responseText
          || req.statusText
          || (req.status == req.UNSENT && "lost connection")
          || "unknown error");
      };
      req.open('POST', '/execute');
      req.send(code);
    }

    function addToScrollback(className, innerText) {
      var div = document.createElement('div');
      div.className = className;
      div.innerText = innerText;

      addToScrollbackInner(div);
    }

    function addToScrollbackInner(element) {
      $scrollback.insertBefore(element, $progress);
      window.scrollTo(0, document.body.scrollHeight);
    }

    function freezeCmd() {
      var cmd = document.createElement('div'),
          prompt = document.createElement('span'),
          buffer = document.createElement('span');
      cmd.className = 'cmd';
      prompt.className = 'prompt';
      prompt.innerText = $prompt.innerText;
      buffer.className = 'buffer';
      buffer.innerText = $buffer.value;
      cmd.appendChild(prompt);
      cmd.appendChild(buffer);
      return cmd;
    }

  </script>

</html>
`

// vim: se ft=html si et sw=2 ts=2 sts=2:
// Package web is the entry point for the backend of the web interface of
// Elvish.
package web

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"

	"src.elv.sh/pkg/eval"
	"src.elv.sh/pkg/parse"
	"src.elv.sh/pkg/prog"
	"src.elv.sh/pkg/shell"
)

// Program is the web subprogram.
var Program prog.Program = program{}

type program struct{}

func (program) Run(fds [3]*os.File, f *prog.Flags, args []string) error {
	if !f.Web {
		return prog.ErrNotSuitable
	}
	if len(args) > 0 {
		return prog.BadUsage("arguments are not allowed with -web")
	}
	if f.CodeInArg {
		return prog.BadUsage("-c cannot be used together with -web")
	}
	p := Web{Port: f.Port}
	return p.Main(fds, nil)
}

type Web struct {
	Port int
}

type httpHandler struct {
	ev *eval.Evaler
}

type ExecuteResponse struct {
	OutBytes  string
	OutValues []interface{}
	ErrBytes  string
	Err       string
}

func (web *Web) Main(fds [3]*os.File, _ []string) error {
	restore := shell.IncSHLVL()
	defer restore()
	ev := shell.MakeEvaler(fds[2])

	h := httpHandler{ev}

	http.HandleFunc("/", h.handleMainPage)
	http.HandleFunc("/execute", h.handleExecute)
	addr := fmt.Sprintf("localhost:%d", web.Port)
	log.Println("going to listen", addr)
	err := http.ListenAndServe(addr, nil)

	log.Println(err)
	return nil
}

func (h httpHandler) handleMainPage(w http.ResponseWriter, r *http.Request) {
	_, err := w.Write([]byte(mainPageHTML))
	if err != nil {
		log.Println("cannot write response:", err)
	}
}

func (h httpHandler) handleExecute(w http.ResponseWriter, r *http.Request) {
	bytes, err := io.ReadAll(r.Body)
	if err != nil {
		log.Println("cannot read request body:", err)
		return
	}
	code := string(bytes)

	outBytes, outValues, errBytes, err := evalAndCollect(h.ev, code)
	errText := ""
	if err != nil {
		errText = err.Error()
	}
	responseBody, err := json.Marshal(
		&ExecuteResponse{string(outBytes), outValues, string(errBytes), errText})
	if err != nil {
		log.Println("cannot marshal response body:", err)
	}

	_, err = w.Write(responseBody)
	if err != nil {
		log.Println("cannot write response:", err)
	}
}

const (
	outFileBufferSize = 1024
	outChanBufferSize = 32
)

// evalAndCollect evaluates a piece of code with null stdin, and stdout and
// stderr connected to pipes (value part of stderr being a blackhole), and
// return the results collected on stdout and stderr, and the possible error
// that occurred.
func evalAndCollect(ev *eval.Evaler, code string) (
	outBytes []byte, outValues []interface{}, errBytes []byte, err error) {

	outFile, chanOutBytes := makeBytesWriterAndCollect()
	outChan, chanOutValues := makeValuesWriterAndCollect()
	errFile, chanErrBytes := makeBytesWriterAndCollect()

	ports := []*eval.Port{
		eval.DummyInputPort,
		{File: outFile, Chan: outChan},
		{File: errFile, Chan: eval.BlackholeChan},
	}
	err = ev.Eval(
		parse.Source{Name: "[web]", Code: code}, eval.EvalCfg{Ports: ports})

	outFile.Close()
	close(outChan)
	errFile.Close()
	return <-chanOutBytes, <-chanOutValues, <-chanErrBytes, err
}

// makeBytesWriterAndCollect makes an in-memory file that can be written to, and
// the written bytes will be collected in a byte slice that will be put on a
// channel as soon as the writer is closed.
func makeBytesWriterAndCollect() (*os.File, <-chan []byte) {
	r, w, err := os.Pipe()
	// os.Pipe returns error only on resource exhaustion.
	if err != nil {
		panic(err)
	}
	chanCollected := make(chan []byte)

	go func() {
		var (
			collected []byte
			buf       [outFileBufferSize]byte
		)
		for {
			n, err := r.Read(buf[:])
			collected = append(collected, buf[:n]...)
			if err != nil {
				if err != io.EOF {
					log.Println("error when reading output pipe:", err)
				}
				break
			}
		}
		r.Close()
		chanCollected <- collected
	}()

	return w, chanCollected
}

// makeValuesWriterAndCollect makes a Value channel for writing, and the written
// values will be collected in a Value slice that will be put on a channel as
// soon as the writer is closed.
func makeValuesWriterAndCollect() (chan interface{}, <-chan []interface{}) {
	chanValues := make(chan interface{}, outChanBufferSize)
	chanCollected := make(chan []interface{})

	go func() {
		var collected []interface{}
		for {
			for v := range chanValues {
				collected = append(collected, v)
			}
			chanCollected <- collected
		}
	}()

	return chanValues, chanCollected
}
package web

import (
	"testing"

	. "src.elv.sh/pkg/prog/progtest"
)

func TestProgram(t *testing.T) {
	Test(t, Program,
		ThatElvish("-web", "x").
			ExitsWith(2).
			WritesStderrContaining("arguments are not allowed with -web"),

		ThatElvish("-web", "-c").
			ExitsWith(2).
			WritesStderrContaining("-c cannot be used together with -web"),
	)
}

Fixed code: