<%= live_flash(@flash, :info) %>
5 | 6 |<%= live_flash(@flash, :error) %>
9 | 10 |├── .dockerignore ├── .eslintrc.js ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── elixir.yml ├── .gitignore ├── .tool-versions ├── Dockerfile ├── LICENSE.md ├── Procfile ├── README.md ├── assets ├── .babelrc ├── css │ └── app.css ├── js │ └── app.js └── tailwind.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── elixir_buildpack.config ├── fly.toml ├── lib ├── elixir_console.ex ├── elixir_console │ ├── application.ex │ ├── autocomplete.ex │ ├── contextual_help.ex │ ├── documentation.ex │ ├── elixir_safe_parts.ex │ ├── sandbox.ex │ └── sandbox │ │ ├── allowed_elixir_modules.ex │ │ ├── code_executor.ex │ │ ├── command_validator.ex │ │ ├── erlang_modules_absence.ex │ │ ├── exclude_conversion_to_atoms.ex │ │ ├── runtime_validations.ex │ │ ├── safe_kernel_functions.ex │ │ └── util.ex ├── elixir_console_web.ex └── elixir_console_web │ ├── endpoint.ex │ ├── live │ ├── console_live.ex │ ├── console_live │ │ ├── command_input_component.ex │ │ ├── helpers.ex │ │ ├── history_component.ex │ │ └── sidebar_component.ex │ └── live_monitor.ex │ ├── plugs │ └── heroku_redirect.ex │ ├── router.ex │ ├── templates │ └── layout │ │ ├── live.html.heex │ │ └── root.html.heex │ └── views │ ├── error_view.ex │ └── layout_view.ex ├── mix.exs ├── mix.lock ├── phoenix_static_buildpack.config ├── priv └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot └── test ├── elixir_console ├── autocomplete_test.exs ├── contextual_help_test.exs ├── sandbox │ ├── command_validator_test.exs │ └── runtime_validations_test.exs └── sandbox_test.exs ├── elixir_console_web ├── features │ └── console_test.exs ├── live │ └── console_live_test.exs └── views │ ├── error_view_test.exs │ └── layout_view_test.exs ├── support └── conn_case.ex └── test_helper.exs /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /*eslint-env node*/ 2 | module.exports = { 3 | "parserOptions": { 4 | "ecmaVersion": 6, 5 | "sourceType": "module" 6 | }, 7 | "env": { 8 | "browser": true 9 | }, 10 | "globals": { 11 | "require": "readonly", 12 | "__dirname": "readonly", 13 | "module": "writable" 14 | }, 15 | 'rules': { 16 | 'camelcase': 2, 17 | 'comma-dangle': [2, 'never'], 18 | 'comma-style': [2, 'last'], 19 | 'eqeqeq': 2, 20 | 'indent': [2, 2, { 'VariableDeclarator': 2 }], 21 | 'no-eq-null': 2, 22 | 'no-extra-parens': 2, 23 | 'no-extra-semi': 2, 24 | 'no-lonely-if': 2, 25 | 'no-multi-spaces': 0, 26 | 'no-nested-ternary': 2, 27 | 'no-param-reassign': 2, 28 | 'no-self-compare': 2, 29 | 'no-shadow': 2, 30 | 'no-throw-literal': 2, 31 | 'no-undef': 2, 32 | 'no-underscore-dangle': 0, 33 | 'no-void': 2, 34 | 'quotes': [2, 'single'], 35 | 'semi': [2, 'always'] 36 | } 37 | }; -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "mix" 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "weekly" 10 | open-pull-requests-limit: 2 11 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build and test 8 | runs-on: ubuntu-20.04 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Elixir 13 | uses: erlef/setup-beam@v1 14 | with: 15 | elixir-version: '1.13.2' 16 | otp-version: '24.0.6' 17 | - name: Restore dependencies cache 18 | uses: actions/cache@v2 19 | with: 20 | path: deps 21 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 22 | restore-keys: ${{ runner.os }}-mix- 23 | - name: Install mix dependencies 24 | run: mix deps.get 25 | - name: Check unused mix dependencies 26 | run: mix deps.unlock --check-unused 27 | - name: Check code format 28 | run: mix format --check-formatted 29 | - name: Compile elixir deps 30 | run: MIX_ENV=test mix deps.compile 31 | - name: Compile elixir project 32 | run: MIX_ENV=test mix compile --force --warnings-as-errors 33 | - name: Run tests 34 | run: mix test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | elixir_console-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | 36 | # elixir files 37 | .elixir_ls/ 38 | 39 | /screenshots 40 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.13.2-otp-24 2 | erlang 24.0.6 3 | nodejs 16.9.1 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.13.2-erlang-24.0.6-debian-bullseye-20210902-slim 14 | # 15 | ARG ELIXIR_VERSION=1.13.2 16 | ARG OTP_VERSION=24.0.6 17 | ARG DEBIAN_VERSION=bullseye-20210902-slim 18 | 19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 21 | 22 | FROM ${BUILDER_IMAGE} as builder 23 | 24 | # install build dependencies 25 | RUN apt-get update -y && apt-get install -y build-essential git \ 26 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 27 | 28 | # prepare build dir 29 | WORKDIR /app 30 | 31 | # install hex + rebar 32 | RUN mix local.hex --force && \ 33 | mix local.rebar --force 34 | 35 | # set build ENV 36 | ENV MIX_ENV="prod" 37 | 38 | # install mix dependencies 39 | COPY mix.exs mix.lock ./ 40 | RUN mix deps.get --only $MIX_ENV 41 | RUN mkdir config 42 | 43 | # copy compile-time config files before we compile dependencies 44 | # to ensure any relevant config change will trigger the dependencies 45 | # to be re-compiled. 46 | COPY config/config.exs config/${MIX_ENV}.exs config/ 47 | RUN mix deps.compile 48 | 49 | COPY priv priv 50 | 51 | COPY lib lib 52 | 53 | COPY assets assets 54 | 55 | # # compile assets 56 | RUN mix assets.deploy 57 | 58 | # Compile the release 59 | RUN mix compile 60 | 61 | # Changes to config/runtime.exs don't require recompiling the code 62 | COPY config/runtime.exs config/ 63 | 64 | COPY rel rel 65 | RUN mix release 66 | 67 | # start a new build stage so that the final image will only contain 68 | # the compiled release and other runtime necessities 69 | FROM ${RUNNER_IMAGE} 70 | 71 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 72 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 73 | 74 | # Set the locale 75 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 76 | 77 | ENV LANG en_US.UTF-8 78 | ENV LANGUAGE en_US:en 79 | ENV LC_ALL en_US.UTF-8 80 | 81 | # Appended by flyctl 82 | ENV ECTO_IPV6 true 83 | ENV ERL_AFLAGS "-proto_dist inet6_tcp" 84 | 85 | WORKDIR "/app" 86 | RUN chown nobody /app 87 | 88 | # set runner ENV 89 | ENV MIX_ENV="prod" 90 | 91 | # Only copy the final release from the build stage 92 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/elixir_console ./ 93 | 94 | USER nobody 95 | 96 | CMD ["/app/bin/server"] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019 WyeWorks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: mix phx.server -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://travis-ci.org/wyeworks/elixir_console) 2 | 3 | --- 4 | 5 | # Elixir Web Console 6 | 7 | The [Elixir Web Console](https://elixirconsole.wyeworks.com/) is a virtual place where people can try the [Elixir language](https://elixir-lang.org/) without the need to leave the browser or installing it on their computers. While this is a project in its early stages, we hope this is a contribution to the effort to promote the language, providing a convenient way to assess the capabilities of this technology. We would love to hear ideas about how to extend this project to serve this purpose better (see the Contributing section). 8 | 9 | This project is inspired in existing playground sites from distinct communities, such as [SwiftPlayground](http://online.swiftplayground.run/) and [Rust Playground](https://play.rust-lang.org/), yet, it is unique because it aims to mimic the [Elixir interactive shell (iEX)](https://hexdocs.pm/iex/IEx.html). 10 | 11 | # Features 12 | 13 | * Bindings are persisted through the current session. Users can assign values to variables. They will remain visible at the side of the screen. 14 | * Pressing the Tab key displays a list of suggestions based on what the user is currently typing in the command input. If only one option is available, the word is autocompleted. It will help to discover modules and public functions. Existing binding names are also taken into account to form the list of suggestions. 15 | * The console presents the history of executed commands and results. The sidebar will display the documentation of Elixir functions when the user clicks on them. 16 | * There is easy access to already-executed commands pressing Up and Down keys when the focus is in the command input. 17 | 18 |  19 | 20 | # Where my Elixir code is executed? 21 | 22 | Unlike other playground projects, it does not rely on spawning sandbox nodes or additional servers to run the code. The Elixir application that is also serving the web page is also responsible for executing the user code. 23 | 24 | Of course, there are plenty of security considerations related to the execution of untrusted code. However, we came up with a solution that allows us to execute them directly on our Elixir backend (see next section). 25 | 26 | The system executes every submitted command in the context of a dedicated process. Note that subsequent invocations to `self()` will return the same PID value. Since this is an isolated process, the executed code should not interfere with the LiveView channel or any other process in the system. 27 | 28 | # How much Elixir can I run in the web console? 29 | 30 | As you might guess, not all Elixir code coming from unknown people on the internet is safe to execute in our online console. The system checks the code before running it. Code relying on certain parts of the language will cause an error message explaining the limitations to the user. 31 | 32 | ## Elixir safe modules and Kernel functions 33 | 34 | The console has a whitelist that includes modules and functions of Elixir considered safe to execute. The system will inform about this limitation when users attempt to use disallowed stuff, such as modules providing access to the file system, the network, and the operating system, among others. 35 | 36 | Moreover, the mentioned whitelist does exclude the metaprogramming functionality of Elixir. The functions [`Kernel.apply/2`](https://hexdocs.pm/elixir/Kernel.html#apply/2) and [`Kernel.apply/3`](https://hexdocs.pm/elixir/Kernel.html#apply/3) are also out of the whitelist to prevent the indirect invocation of not-secure functions. 37 | 38 | The AST of the user code is verified before its actual execution, checking if the function invocations are not dangerous. However, some cases are impossible to detect by only inspecting at the code. For this reason, a layer of runtime validations exists as well, providing an additional way to detect non-secure invocations that only happens when the user code computes the callee at runtime. Both approaches have some overlap, and the design of the solution has room for improvement, so we expect to work on having a more refined solution shortly. 39 | 40 | ## Processes 41 | 42 | The console currently does not allow the usage of the module `Process` and other parts of Elixir related to processes. Existing resource usage limitations (see next section) would be much harder to enforce if users were permitted to spawn processes. Similarly, the functions `send` and `receive` are restricted to avoid integrity and security problems. 43 | 44 | **This limitation does not make us happy** because it would be valuable to let people play with processes within our interactive shell. We are currently thinking about manners to include those modules and functions within the whitelist. Extra precaution is needed to implement it due to possible security implications. 45 | 46 | ## The problem with atoms 47 | 48 | It represents a tricky issue for our web console because in Elixir/Erlang atoms are never garbage collected. Therefore, each atom created by users code will be added to the global list of existing atoms. It means that, eventually, the [maximum number of atoms](http://erlang.org/doc/efficiency_guide/advanced.html#atoms) will be reached, causing a server crash. 49 | 50 | We consider this issue is not an impediment to have the server operating, at least for now. In case of a crash due to the overflow of atoms, Heroku will automatically restart the application. 51 | 52 | When the server is restarted, any existing sessions are lost. Of course, it would be problematic if it happens often. We are monitoring the server to diagnose the relevance of this issue better. Hopefully, it will require some server restart from time to time, and we have some ideas to automate it in the future. 53 | 54 | To mitigate this problem, the function `String.to_atom/1` is not available in the console limiting the creation of a large number of atoms programmatically. 55 | 56 | We are confident that the number of created atoms will grow relatively slow, giving us time to restore the server if this is ever needed. 57 | 58 | # Other limitations 59 | 60 | The execution of code in this console is limited by the backend logic in additional ways in an attempt to preserve the server health and being able to attend a more significant number of users. 61 | 62 | Each submitted command should run in a limited number of seconds; otherwise, a timeout error is returned. Moreover, the execution of the piece of code must respect a memory usage limit. 63 | 64 | The length of the command itself (the number of characters) is limited as well. This restriction exists due to security and resource-saving reasons. 65 | 66 | # Roadmap 67 | 68 | While a refined ongoing plan does not exist yet, the following is a list of possible improvements. 69 | 70 | * Add the ability to write multiline code. 71 | * Extract the Elixir Sandbox functionality to a package. 72 | * Allow spawning a limited amount of processes. 73 | * Try to permit the definition of modules and structs. 74 | * Implement sandboxed versions of individual restricted modules and functions (for example, a fake implementation of the filesystem functions). 75 | * Provide controlled access to additional concurrency-related functionality (send/receive, Agent, GenServer), if possible. 76 | * Overcome the problem with atoms (we are working on a prototype to confirm if this is feasible). 77 | 78 | # About this project 79 | 80 | This project was initially implemented to participate in the [Phoenix Phrenzy](https://phoenixphrenzy.com) contest. It is an example of the capabilities of [Phoenix](https://phoenixframework.org/) and [LiveView](https://github.com/phoenixframework/phoenix_live_view). 81 | 82 | Beyond its primary purpose, this is a research initiative. We are exploring the implications of executing untrusted Elixir code in a sandboxed manner. In particular, we want to solve it without using extra infrastructure, being as accessible and easy to use as possible. We have plans to create a package that includes the sandbox functionality. This package would enable the usage of Elixir as a scripting language (although we are not sure if this is a good idea). 83 | 84 | # Special Thanks 85 | 86 | The authors of the project are [Noelia](https://github.com/noelia-lencina), [Ignacio](https://github.com/iaguirre88), [Javier](https://github.com/JavierM42), and [Jorge](https://github.com/jmbejar). Special thanks to [WyeWorks](https://www.wyeworks.com) for providing working hours to dedicate to this project. 87 | 88 | We also want to publicly thanks [Marcelo Dominguez](https://github.com/marpo60), [Allen Madsen](https://github.com/noelia-lencina), [Gabriel Roldán](https://github.com/luisgabrielroldan), [Luis Ferreira](https://github.com/hidnasio), and [ 89 | Ben Hu](https://github.com/Ben-Hu) for testing the console and reporting several security issues. 90 | 91 | # Contributing 92 | 93 | Please feel free to open issues or pull requests. Both things will help us to extend and improve the Elixir Web Console 🎉 94 | We know that security vulnerabilities probably exist due to the nature of the technical challenge. If you have found a security issue, please send us a note privately at [elixir-console-security@wyeworks.com](mailto:elixir-console-security@wyeworks.com). 95 | 96 | ## Runing tests 97 | 98 | ``` 99 | $ mix test 100 | ``` 101 | 102 | To run feature tests Wallaby [requires](https://hexdocs.pm/wallaby/Wallaby.Chrome.html#module-notes) you to have Chrome or Chromedriver in your PATH. 103 | 104 | Headful mode: 105 | 106 | ``` 107 | $ HEADLESS=false mix test 108 | ``` 109 | 110 | # License 111 | 112 | Elixir Web Console is released under the [MIT License](https://github.com/wyeworks/elixir_console/blob/master/LICENSE.md). 113 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | 3 | /* csslint ignore:start */ 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | /* csslint ignore:end */ 8 | 9 | .live-view-parent > div { 10 | height: 100%; 11 | } 12 | 13 | body { 14 | font-family: 'Fira Mono', monospace; 15 | } 16 | 17 | /* extending tailwind provided classes */ 18 | .max-h-1\/3 { 19 | max-height: 33%; 20 | } 21 | 22 | /* styling contextual help (Utility-first paradigm is not useful here since it's inserted raw) */ 23 | .contextual-help-doc h2 { /* csslint allow: qualified-headings */ 24 | margin-bottom: 0.5rem; 25 | margin-top: 0.5rem; 26 | } 27 | 28 | .contextual-help-doc pre { 29 | @apply bg-teal-900; 30 | @apply text-teal-300; 31 | border-radius: 4px; 32 | margin-bottom: 0.5rem; 33 | padding: 0.25rem 0.5rem; 34 | white-space: pre-wrap; 35 | } 36 | 37 | .contextual-help-doc p > code { 38 | @apply bg-teal-900; 39 | @apply text-teal-300; 40 | border-radius: 2px; 41 | padding: 0.25rem; 42 | } 43 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import "phoenix_html" 2 | import {Socket} from "phoenix" 3 | import {LiveSocket} from "phoenix_live_view" 4 | 5 | let Hooks = {}; 6 | Hooks.CommandInput = { 7 | mounted() { 8 | const el = this.el; 9 | this.handleEvent("reset", () => { 10 | el.value = ""; 11 | }); 12 | el.addEventListener("keydown", (e) => { 13 | if (e.code === "Tab") { 14 | this.pushEventTo( 15 | "#commandInput", 16 | "suggest", 17 | {"value": el.value, "caret_position": el.selectionEnd} 18 | ); 19 | } else if (e.code === "ArrowUp") { 20 | this.pushEventTo("#commandInput", "cycle_history_up"); 21 | } else if (e.code === "ArrowDown") { 22 | this.pushEventTo("#commandInput", "cycle_history_down"); 23 | } 24 | }); 25 | }, 26 | updated() { 27 | const newValue = this.el.dataset.input_value; 28 | const newCaretPosition = parseInt(this.el.dataset.caret_position); 29 | 30 | if (newValue !== "") { 31 | this.el.value = newValue; 32 | this.el.setSelectionRange(newCaretPosition, newCaretPosition); 33 | } 34 | } 35 | }; 36 | 37 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 38 | /* eslint-disable camelcase */ 39 | let liveSocket = new LiveSocket( 40 | "/live", 41 | Socket, 42 | { 43 | params: {_csrf_token: csrfToken}, 44 | hooks: Hooks, 45 | metadata: { 46 | keydown: (_e, el) => { 47 | return {caret_position: el.selectionEnd}; 48 | } 49 | } 50 | } 51 | ); 52 | /* eslint-enable camelcase */ 53 | 54 | // connect if there are any LiveViews on the page 55 | liveSocket.connect() 56 | 57 | // expose liveSocket on window for web console debug logs and latency simulation: 58 | // >> liveSocket.enableDebug() 59 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 60 | // >> liveSocket.disableLatencySim() 61 | window.liveSocket = liveSocket 62 | 63 | document.addEventListener("DOMContentLoaded", () => { 64 | const input = document.getElementById("commandInput"); 65 | input.addEventListener("keydown", (e) => { 66 | if (e.code === "Tab") { 67 | e.preventDefault(); 68 | } 69 | }, true); 70 | }); 71 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | let plugin = require('tailwindcss/plugin'); 5 | const colors = require('tailwindcss/colors'); 6 | 7 | module.exports = { 8 | content: [ 9 | './js/**/*.js', 10 | '../lib/*_web.ex', 11 | '../lib/*_web/**/*.*ex' 12 | ], 13 | theme: { 14 | extend: { 15 | colors: { 16 | teal: colors.teal 17 | } 18 | }, 19 | }, 20 | plugins: [ 21 | require('@tailwindcss/forms'), 22 | plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), 23 | plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), 24 | plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), 25 | plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :elixir_console, ElixirConsoleWeb.Endpoint, 12 | url: [host: "localhost"], 13 | secret_key_base: "xkn0Oy0t0ydkJkKxKwFVJ36lc5MX7kHtdo+4vtEVqGrBNN/Kv4a9GIGqbx6CHlVw", 14 | render_errors: [view: ElixirConsoleWeb.ErrorView, accepts: ~w(html json), layout: false], 15 | pubsub_server: ElixirConsole.PubSub, 16 | live_view: [signing_salt: "7vohZO+j"] 17 | 18 | # Configure esbuild (the version is required) 19 | config :esbuild, 20 | version: "0.14.29", 21 | default: [ 22 | args: 23 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 24 | cd: Path.expand("../assets", __DIR__), 25 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 26 | ] 27 | 28 | config :tailwind, 29 | version: "3.3.2", 30 | default: [ 31 | args: ~w( 32 | --config=tailwind.config.js 33 | --input=css/app.css 34 | --output=../priv/static/assets/app.css 35 | ), 36 | cd: Path.expand("../assets", __DIR__) 37 | ] 38 | 39 | # Configures Elixir's Logger 40 | config :logger, :console, 41 | format: "$time $metadata[$level] $message\n", 42 | metadata: [:request_id] 43 | 44 | # Use Jason for JSON parsing in Phoenix 45 | config :phoenix, :json_library, Jason 46 | 47 | # Import environment specific config. This must remain at the bottom 48 | # of this file so it overrides the configuration defined above. 49 | import_config "#{Mix.env()}.exs" 50 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | config :elixir_console, ElixirConsoleWeb.Endpoint, 6 | http: [port: 4000], 7 | debug_errors: true, 8 | code_reloader: true, 9 | check_origin: false, 10 | secret_key_base: "NMIEOZnzwCYSnDEV234iGpRihRaAev6qzgkzPf5//LoR7QasoylrntyZckoRhMPT", 11 | watchers: [ 12 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 13 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 14 | ] 15 | 16 | # ## SSL Support 17 | # 18 | # In order to use HTTPS in development, a self-signed 19 | # certificate can be generated by running the following 20 | # Mix task: 21 | # 22 | # mix phx.gen.cert 23 | # 24 | # Note that this task requires Erlang/OTP 20 or later. 25 | # Run `mix help phx.gen.cert` for more information. 26 | # 27 | # The `http:` config above can be replaced with: 28 | # 29 | # https: [ 30 | # port: 4001, 31 | # cipher_suite: :strong, 32 | # keyfile: "priv/cert/selfsigned_key.pem", 33 | # certfile: "priv/cert/selfsigned.pem" 34 | # ], 35 | # 36 | # If desired, both `http:` and `https:` keys can be 37 | # configured to run both http and https servers on 38 | # different ports. 39 | 40 | # Watch static and templates for browser reloading. 41 | config :elixir_console, ElixirConsoleWeb.Endpoint, 42 | live_reload: [ 43 | patterns: [ 44 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 45 | ~r"lib/elixir_console_web/(live|views)/.*(ex)$", 46 | ~r"lib/elixir_console_web/templates/.*(eex)$" 47 | ] 48 | ] 49 | 50 | # Do not include metadata nor timestamps in development logs 51 | config :logger, :console, format: "[$level] $message\n" 52 | 53 | # Set a higher stacktrace during development. Avoid configuring such 54 | # in production as building large stacktraces may be expensive. 55 | config :phoenix, :stacktrace_depth, 20 56 | 57 | # Initialize plugs at runtime for faster development compilation 58 | config :phoenix, :plug_init_mode, :runtime 59 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :elixir_console, ElixirConsoleWeb.Endpoint, 13 | cache_static_manifest: "priv/static/cache_manifest.json" 14 | 15 | # Do not print debug messages in production 16 | config :logger, level: :info, backends: [:console, Sentry.LoggerBackend] 17 | 18 | # ## SSL Support 19 | # 20 | # To get SSL working, you will need to add the `https` key 21 | # to the previous section and set your `:url` port to 443: 22 | # 23 | # config :elixir_console, ElixirConsoleWeb.Endpoint, 24 | # ... 25 | # url: [host: "example.com", port: 443], 26 | # https: [ 27 | # port: 443, 28 | # cipher_suite: :strong, 29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH"), 31 | # transport_options: [socket_opts: [:inet6]] 32 | # ] 33 | # 34 | # The `cipher_suite` is set to `:strong` to support only the 35 | # latest and more secure SSL ciphers. This means old browsers 36 | # and clients may not be supported. You can set it to 37 | # `:compatible` for wider support. 38 | # 39 | # `:keyfile` and `:certfile` expect an absolute path to the key 40 | # and cert in disk or a relative path inside priv, for example 41 | # "priv/ssl/server.key". For all supported SSL configuration 42 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 43 | # 44 | # We also recommend setting `force_ssl` in your endpoint, ensuring 45 | # no data is ever sent via http, always redirecting to https: 46 | # 47 | # config :elixir_console, ElixirConsoleWeb.Endpoint, 48 | # force_ssl: [hsts: true] 49 | # 50 | # Check `Plug.SSL` for all available options in `force_ssl`. 51 | 52 | config :sentry, 53 | dsn: "https://55dc7d86c2454858830dda49bb24962d@sentry.io/1855223", 54 | environment_name: System.get_env("RELEASE_LEVEL") || "development", 55 | enable_source_code_context: true, 56 | root_source_code_path: File.cwd!(), 57 | tags: %{ 58 | env: "production" 59 | }, 60 | included_environments: ~w(production staging) 61 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/hello_world start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :elixir_console, ElixirConsoleWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | # The secret key base is used to sign/encrypt cookies and other secrets. 25 | # A default value is used in config/dev.exs and config/test.exs but you 26 | # want to use a different value for prod and you most likely don't want 27 | # to check this value into version control, so we use an environment 28 | # variable instead. 29 | secret_key_base = 30 | System.get_env("SECRET_KEY_BASE") || 31 | raise """ 32 | environment variable SECRET_KEY_BASE is missing. 33 | You can generate one by calling: mix phx.gen.secret 34 | """ 35 | 36 | host = System.get_env("PHX_HOST") || "example.com" 37 | port = String.to_integer(System.get_env("PORT") || "4000") 38 | 39 | config :elixir_console, ElixirConsoleWeb.Endpoint, 40 | url: [host: host, port: 443, scheme: "https"], 41 | http: [ 42 | # Enable IPv6 and bind on all interfaces. 43 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 44 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 45 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 46 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 47 | port: port 48 | ], 49 | secret_key_base: secret_key_base 50 | 51 | # ## Configuring the mailer 52 | # 53 | # In production you need to configure the mailer to use a different adapter. 54 | # Also, you may need to configure the Swoosh API client of your choice if you 55 | # are not using SMTP. Here is an example of the configuration: 56 | # 57 | # config :hello_world, HelloWorld.Mailer, 58 | # adapter: Swoosh.Adapters.Mailgun, 59 | # api_key: System.get_env("MAILGUN_API_KEY"), 60 | # domain: System.get_env("MAILGUN_DOMAIN") 61 | # 62 | # For this example you need include a HTTP client required by Swoosh API client. 63 | # Swoosh supports Hackney and Finch out of the box: 64 | # 65 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 66 | # 67 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 68 | end 69 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :elixir_console, ElixirConsoleWeb.Endpoint, 6 | http: [port: 4002], 7 | server: true 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | config :wallaby, 13 | chromedriver: [headless: System.get_env("HEADLESS") != "false"], 14 | screenshot_on_failure: true 15 | 16 | config :phoenix, :plug_init_mode, :runtime 17 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | elixir_version=1.13.2 2 | erlang_version=24.0.6 3 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for elixir-console-wye on 2023-05-18T09:26:47-03:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "elixir-console-wye" 7 | primary_region = "phx" 8 | kill_signal = "SIGTERM" 9 | 10 | [env] 11 | PHX_HOST = "elixirconsole.wyeworks.com" 12 | PORT = "8080" 13 | 14 | [http_service] 15 | internal_port = 8080 16 | force_https = true 17 | auto_stop_machines = true 18 | auto_start_machines = true 19 | min_machines_running = 0 20 | [http_service.concurrency] 21 | type = "connections" 22 | hard_limit = 1000 23 | soft_limit = 1000 24 | -------------------------------------------------------------------------------- /lib/elixir_console.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole do 2 | @moduledoc """ 3 | ElixirConsole keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/elixir_console/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | children = [ 10 | # Start the PubSub system 11 | {Phoenix.PubSub, name: ElixirConsole.PubSub}, 12 | # Start the Endpoint (http/https) 13 | ElixirConsoleWeb.Endpoint, 14 | ElixirConsoleWeb.LiveMonitor, 15 | ElixirConsole.Documentation 16 | # Start a worker by calling: ElixirConsole.Worker.start_link(arg) 17 | # {ElixirConsole.Worker, arg} 18 | ] 19 | 20 | # See https://hexdocs.pm/elixir/Supervisor.html 21 | # for other strategies and supported options 22 | opts = [strategy: :one_for_one, name: ElixirConsole.Supervisor] 23 | Supervisor.start_link(children, opts) 24 | end 25 | 26 | # Tell Phoenix to update the endpoint configuration 27 | # whenever the application is updated. 28 | def config_change(changed, _new, removed) do 29 | ElixirConsoleWeb.Endpoint.config_change(changed, removed) 30 | :ok 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/elixir_console/autocomplete.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Autocomplete do 2 | @moduledoc """ 3 | Encapsulates all the logic related with the autocomplete feature 4 | """ 5 | 6 | alias ElixirConsole.Documentation 7 | 8 | @max_command_length 10_000 9 | 10 | @doc """ 11 | Get a list of suggestions with all the possible words that could fit in the 12 | command that is being typed by the user. 13 | """ 14 | def get_suggestions(value, caret_position, bindings) do 15 | word_to_autocomplete = word_to_autocomplete(value, caret_position) 16 | modules_or_functions = modules_or_functions_from_docs(word_to_autocomplete) 17 | 18 | bindings 19 | |> all_suggestions_candidates(modules_or_functions) 20 | |> filter_suggestions(word_to_autocomplete) 21 | end 22 | 23 | defp all_suggestions_candidates(bindings, modules_or_functions) do 24 | bindings_variable_names(bindings) ++ elixir_library_names(modules_or_functions) 25 | end 26 | 27 | defp bindings_variable_names(bindings) do 28 | bindings 29 | |> Enum.map(fn {name, _} -> Atom.to_string(name) end) 30 | |> Enum.sort() 31 | end 32 | 33 | defp modules_or_functions_from_docs(word_to_autocomplete) do 34 | cond do 35 | String.match?(word_to_autocomplete, ~r/^[A-Z]\w*\.\w*$/) -> 36 | :functions 37 | 38 | String.match?(word_to_autocomplete, ~r/^[a-z]/) -> 39 | :kernel_functions 40 | 41 | true -> 42 | :modules 43 | end 44 | end 45 | 46 | defp elixir_library_names(modules_or_functions) do 47 | modules_or_functions 48 | |> retrieve_names_from_documentation() 49 | |> Enum.sort() 50 | end 51 | 52 | defp retrieve_names_from_documentation(:functions), do: Documentation.get_functions_names() 53 | 54 | defp retrieve_names_from_documentation(:kernel_functions), 55 | do: Documentation.get_kernel_functions_names() 56 | 57 | defp retrieve_names_from_documentation(:modules), do: Documentation.get_modules_names() 58 | 59 | defp filter_suggestions(candidates, word_to_autocomplete) do 60 | candidates 61 | |> Enum.filter(&String.starts_with?(&1, word_to_autocomplete)) 62 | |> Enum.take(10) 63 | end 64 | 65 | @doc """ 66 | Returns a modified version of the command input value with an autocompleted 67 | word. It means that the `suggestion` value is used to replace the word that 68 | ends in the `caret_position` position of the provided `value`. 69 | 70 | It returns a tuple with the new input command (modified with the autocompleted 71 | word) and the new caret position (right after the last character of the 72 | autocompleted word) 73 | """ 74 | def autocompleted_input(value, caret_position, autocompleted_word) do 75 | word_to_autocomplete = word_to_autocomplete(value, caret_position) 76 | 77 | { 78 | calculate_new_input_value(value, caret_position, word_to_autocomplete, autocompleted_word), 79 | calculate_new_caret_position(caret_position, word_to_autocomplete, autocompleted_word) 80 | } 81 | end 82 | 83 | defp word_to_autocomplete(value, caret_position) do 84 | {value_until_caret, _} = split_command_for_autocomplete(value, caret_position) 85 | value_until_caret |> String.split() |> List.last() || "" 86 | end 87 | 88 | defp split_command_for_autocomplete(value, caret_position) do 89 | {String.slice(value, 0, caret_position), 90 | String.slice(value, caret_position, @max_command_length)} 91 | end 92 | 93 | defp calculate_new_caret_position(caret_position, word_to_autocomplete, autocompleted_word) do 94 | String.length(autocompleted_word) - String.length(word_to_autocomplete) + caret_position 95 | end 96 | 97 | defp calculate_new_input_value( 98 | input_value, 99 | caret_position, 100 | word_to_autocomplete, 101 | autocompleted_word 102 | ) do 103 | {value_until_caret, value_from_caret} = 104 | split_command_for_autocomplete(input_value, caret_position) 105 | 106 | Regex.replace(~r/\.*#{word_to_autocomplete}$/, value_until_caret, autocompleted_word) <> 107 | value_from_caret 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/elixir_console/contextual_help.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.ContextualHelp do 2 | @moduledoc """ 3 | Utilities to add metadata to an user-generated Elixir command about the 4 | standard library functions that are in use. 5 | """ 6 | 7 | alias ElixirConsole.Documentation 8 | 9 | @kernel_binary_operators ~w( 10 | != 11 | !== 12 | * 13 | - 14 | + 15 | / 16 | < 17 | <= 18 | == 19 | === 20 | > 21 | >= 22 | && 23 | ++ 24 | -- 25 | .. 26 | ** 27 | <> 28 | =~ 29 | |> 30 | || 31 | and 32 | or 33 | in 34 | )a 35 | 36 | @kernel_functions ~w( 37 | abs 38 | binary_part 39 | bit_size 40 | byte_size 41 | ceil 42 | div 43 | elem 44 | floor 45 | hd 46 | is_atom 47 | is_binary 48 | is_bitstring 49 | is_boolean 50 | is_exception 51 | is_float 52 | is_function 53 | is_integer 54 | is_list 55 | is_map 56 | is_map_key 57 | is_nil 58 | is_number 59 | is_reference 60 | is_struct 61 | is_tuple 62 | length 63 | map_size 64 | not 65 | rem 66 | round 67 | self 68 | tl 69 | trunc 70 | tuple_size 71 | ! 72 | binding 73 | function_exported? 74 | get_and_update_in 75 | get_in 76 | if 77 | inspect 78 | macro_exported? 79 | make_ref 80 | match? 81 | max 82 | min 83 | pop_in 84 | put_elem 85 | put_in 86 | raise 87 | reraise 88 | struct 89 | struct! 90 | throw 91 | to_charlist 92 | to_string 93 | unless 94 | update_in 95 | tap 96 | then 97 | )a 98 | 99 | @doc """ 100 | Takes an Elixir command and returns it divided in parts, and the ones that 101 | correspond to Elixir functions are augmented with metadata containing the docs 102 | """ 103 | def compute(command) do 104 | case Code.string_to_quoted(command) do 105 | {:ok, expr} -> 106 | functions = find_functions(expr, []) 107 | add_documentation(command, functions) 108 | 109 | _ -> 110 | [command] 111 | end 112 | end 113 | 114 | defp find_functions(list, acc) when is_list(list) do 115 | Enum.reduce(list, acc, fn node, acc -> find_functions(node, acc) end) 116 | end 117 | 118 | defp find_functions({{:., _, [{_, _, [module]}, func_name]}, _, params}, acc) 119 | when is_atom(module) do 120 | acc = acc ++ [%{module: module, func_name: func_name, func_ary: Enum.count(params)}] 121 | Enum.reduce(params, acc, fn node, acc -> find_functions(node, acc) end) 122 | end 123 | 124 | defp find_functions({{:., _, nested_expression}, _, params}, acc) do 125 | acc = find_functions(nested_expression, acc) 126 | Enum.reduce(params, acc, fn node, acc -> find_functions(node, acc) end) 127 | end 128 | 129 | defp find_functions({func_name, _, params}, acc) 130 | when func_name in @kernel_functions and is_list(params) do 131 | acc = acc ++ [%{module: "Kernel", func_name: func_name, func_ary: Enum.count(params)}] 132 | Enum.reduce(params, acc, fn node, acc -> find_functions(node, acc) end) 133 | end 134 | 135 | defp find_functions({func_name, _, [left_param, right_param]}, acc) 136 | when func_name in @kernel_binary_operators do 137 | acc = find_functions(left_param, acc) 138 | acc = acc ++ [%{module: "Kernel", func_name: func_name, func_ary: 2}] 139 | find_functions(right_param, acc) 140 | end 141 | 142 | defp find_functions({_, _, list}, acc) when is_list(list) do 143 | Enum.reduce(list, acc, fn node, acc -> find_functions(node, acc) end) 144 | end 145 | 146 | defp find_functions({node_left, node_right}, acc) do 147 | find_functions(node_right, find_functions(node_left, acc)) 148 | end 149 | 150 | defp find_functions(_, acc), do: acc 151 | 152 | defp add_documentation(command, []), do: [command] 153 | 154 | defp add_documentation( 155 | command, 156 | [%{module: module, func_name: func_name, func_ary: func_ary} | rest] 157 | ) do 158 | func_fullname = "#{module}.#{func_name}" 159 | regex = ~r/#{Regex.escape(func_fullname)}|#{Regex.escape(Atom.to_string(func_name))}/ 160 | 161 | [before_matched, matched, remaining_command] = 162 | Regex.split(regex, command, include_captures: true, parts: 2) 163 | 164 | parts_to_add = 165 | case Documentation.get_doc(%Documentation.Key{func_name: func_fullname, arity: func_ary}) do 166 | nil -> 167 | [before_matched, matched] 168 | 169 | doc -> 170 | [before_matched, {matched, doc}] 171 | end 172 | 173 | parts_to_add ++ add_documentation(remaining_command, rest) 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /lib/elixir_console/documentation.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Documentation do 2 | @moduledoc """ 3 | GenServer that holds Elixir functions/macros documentation in memory for fast 4 | access. 5 | """ 6 | 7 | use GenServer 8 | 9 | defmodule Key do 10 | @enforce_keys [:func_name, :arity] 11 | defstruct @enforce_keys 12 | end 13 | 14 | defmodule DocEntry do 15 | @enforce_keys [:module_name, :func_name, :func_ary, :docs_header, :docs_body] 16 | defstruct @enforce_keys 17 | 18 | @az_range 97..122 19 | 20 | def build_docs_metadata(nil), do: nil 21 | 22 | def build_docs_metadata(doc_entry) do 23 | %DocEntry{ 24 | module_name: module_name, 25 | func_name: func_name, 26 | func_ary: func_ary, 27 | docs_header: docs_header, 28 | docs_body: docs_body 29 | } = doc_entry 30 | 31 | %{ 32 | type: function_or_operator(func_name), 33 | func_name: "#{module_name}.#{func_name}/#{func_ary}", 34 | header: docs_header, 35 | docs: docs_body, 36 | link: "https://hexdocs.pm/elixir/#{module_name}.html##{func_name}/#{func_ary}" 37 | } 38 | end 39 | 40 | defp function_or_operator(func_name) when is_atom(func_name) do 41 | func_name 42 | |> to_charlist 43 | |> function_or_operator 44 | end 45 | 46 | defp function_or_operator([first_char | _]) when first_char in @az_range, do: :function 47 | defp function_or_operator(_), do: :operator 48 | end 49 | 50 | alias ElixirConsole.ElixirSafeParts 51 | @modules ElixirSafeParts.safe_elixir_modules() 52 | @unsafe_kernel_functions ElixirSafeParts.unsafe_kernel_functions() 53 | 54 | def start_link(_) do 55 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__) 56 | end 57 | 58 | @impl true 59 | def init(_) do 60 | {:ok, retrieve_docs(@modules)} 61 | end 62 | 63 | @impl true 64 | def handle_call({:get, key}, _from, docs) do 65 | doc = docs[key] || find_with_different_arity(key, docs) 66 | {:reply, DocEntry.build_docs_metadata(doc), docs} 67 | end 68 | 69 | @impl true 70 | def handle_call(:get_functions_names, _from, docs) do 71 | functions_names = 72 | Map.keys(docs) 73 | |> Enum.map(& &1.func_name) 74 | |> Enum.uniq() 75 | 76 | {:reply, functions_names, docs} 77 | end 78 | 79 | @impl true 80 | def handle_call(:get_kernel_functions_names, _from, docs) do 81 | functions_names = 82 | Map.values(docs) 83 | |> Enum.filter(&(&1.module_name == "Kernel")) 84 | |> Enum.map(&to_string(&1.func_name)) 85 | |> Enum.uniq() 86 | 87 | {:reply, functions_names, docs} 88 | end 89 | 90 | @impl true 91 | def handle_call(:get_modules_names, _from, docs) do 92 | modules_names = 93 | Map.values(docs) 94 | |> Enum.map(& &1.module_name) 95 | |> Enum.uniq() 96 | 97 | {:reply, modules_names, docs} 98 | end 99 | 100 | def get_doc(key), do: GenServer.call(__MODULE__, {:get, key}) 101 | 102 | def get_functions_names(), do: GenServer.call(__MODULE__, :get_functions_names) 103 | def get_kernel_functions_names(), do: GenServer.call(__MODULE__, :get_kernel_functions_names) 104 | def get_modules_names(), do: GenServer.call(__MODULE__, :get_modules_names) 105 | 106 | defp retrieve_docs([]), do: %{} 107 | 108 | defp retrieve_docs([module | remaining_modules]) do 109 | {:docs_v1, _, :elixir, _, _, _, list} = Code.fetch_docs(module) 110 | 111 | docs = 112 | list 113 | |> reject_unsafe_functions(module) 114 | |> build_module_documentation(module) 115 | 116 | Map.merge(docs, retrieve_docs(remaining_modules)) 117 | end 118 | 119 | defp reject_unsafe_functions(function_docs, Kernel) do 120 | Enum.reject(function_docs, fn function -> 121 | case function do 122 | {{_, func_name, _}, _, _, %{"en" => _}, _} 123 | when func_name in @unsafe_kernel_functions -> 124 | true 125 | 126 | _ -> 127 | false 128 | end 129 | end) 130 | end 131 | 132 | defp reject_unsafe_functions(function_docs, _), do: function_docs 133 | 134 | defp build_module_documentation(function_list, module) do 135 | Enum.reduce(function_list, %{}, fn function, acc -> 136 | case function do 137 | {{function_or_macro, func_name, func_ary}, _, header, %{"en" => docs}, _} 138 | when function_or_macro in [:function, :macro] -> 139 | {:ok, html_doc, _} = Earmark.as_html(docs) 140 | [module_name] = Module.split(module) 141 | 142 | Map.put( 143 | acc, 144 | %Key{func_name: "#{module_name}.#{func_name}", arity: func_ary}, 145 | %DocEntry{ 146 | module_name: module_name, 147 | func_name: func_name, 148 | func_ary: func_ary, 149 | docs_header: header, 150 | docs_body: html_doc 151 | } 152 | ) 153 | 154 | _ -> 155 | acc 156 | end 157 | end) 158 | end 159 | 160 | defp find_with_different_arity(%Key{func_name: func_name, arity: func_ary}, docs) do 161 | with {_, doc} <- 162 | Enum.filter(docs, fn {key, _} -> 163 | key.func_name == func_name && key.arity != func_ary 164 | end) 165 | |> Enum.sort(fn {k1, _}, {k2, _} -> k1.arity < k2.arity end) 166 | |> List.first() do 167 | doc 168 | else 169 | nil -> nil 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/elixir_console/elixir_safe_parts.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.ElixirSafeParts do 2 | @moduledoc """ 3 | List of modules and functions considered safe to be executed by the Elixir 4 | Console 5 | 6 | The goal of the console is to run Elixir code from untrusted sources. 7 | Therefore, here is defined a few whitelists of Elixir modules and functions, 8 | in order to limit the accessible feature of the language that are at disposal 9 | """ 10 | 11 | @safe_modules ~w( 12 | Kernel 13 | Atom 14 | Base 15 | Bitwise 16 | Date 17 | DateTime 18 | Float 19 | Integer 20 | NaiveDateTime 21 | Regex 22 | String 23 | Time 24 | Tuple 25 | URI 26 | Version 27 | Version.Requirement 28 | Access 29 | Date.Range 30 | Enum 31 | Keyword 32 | List 33 | Map 34 | MapSet 35 | Range 36 | Stream 37 | OptionParser 38 | Collectable 39 | Enumerable 40 | )a 41 | 42 | @safe_kernel_functions ~w( 43 | ! 44 | && 45 | ++ 46 | -- 47 | .. 48 | ** 49 | <> 50 | =~ 51 | binding 52 | get_and_update_in 53 | get_in 54 | if 55 | inspect 56 | make_ref 57 | match? 58 | max 59 | min 60 | pop_in 61 | put_elem 62 | put_in 63 | raise 64 | reraise 65 | sigil_C 66 | sigil_D 67 | sigil_N 68 | sigil_R 69 | sigil_S 70 | sigil_T 71 | sigil_U 72 | sigil_W 73 | sigil_c 74 | sigil_r 75 | sigil_s 76 | sigil_w 77 | struct 78 | struct! 79 | throw 80 | to_charlist 81 | to_string 82 | unless 83 | update_in 84 | |> 85 | || 86 | != 87 | !== 88 | * 89 | + 90 | - 91 | / 92 | <= 93 | < 94 | == 95 | === 96 | > 97 | >= 98 | abs 99 | and 100 | binary_part 101 | bit_size 102 | byte_size 103 | ceil 104 | div 105 | elem 106 | floor 107 | hd 108 | in 109 | is_atom 110 | is_binary 111 | is_bitstring 112 | is_boolean 113 | is_exception 114 | is_float 115 | is_function 116 | is_integer 117 | is_list 118 | is_map 119 | is_map_key 120 | is_nil 121 | is_number 122 | is_pid 123 | is_port 124 | is_reference 125 | is_struct 126 | is_tuple 127 | length 128 | map_size 129 | not 130 | or 131 | rem 132 | round 133 | self 134 | tl 135 | trunc 136 | tuple_size 137 | % 138 | %{} 139 | & 140 | . 141 | :: 142 | <<>> 143 | = 144 | ^ 145 | __aliases__ 146 | __block__ 147 | case 148 | cond 149 | fn 150 | for 151 | try 152 | with 153 | {} 154 | tap 155 | then 156 | ..// 157 | )a 158 | 159 | def safe_modules, do: @safe_modules 160 | 161 | def safe_elixir_modules, do: Enum.map(@safe_modules, &:"Elixir.#{&1}") 162 | 163 | def unsafe_kernel_functions do 164 | all_kernel_functions() -- @safe_kernel_functions 165 | end 166 | 167 | defp all_kernel_functions do 168 | all_kernel_functions_raw() |> Keyword.keys() |> Enum.uniq() 169 | end 170 | 171 | defp all_kernel_functions_raw do 172 | Kernel.__info__(:functions) ++ 173 | Kernel.__info__(:macros) ++ Kernel.SpecialForms.__info__(:macros) 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox do 2 | @moduledoc """ 3 | Provides a sandbox where Elixir code from untrusted sources can be executed 4 | """ 5 | 6 | @type sandbox() :: %__MODULE__{} 7 | 8 | alias ElixirConsole.Sandbox.CodeExecutor 9 | 10 | @max_command_length 500 11 | @max_memory_kb_default 256 12 | @max_binary_memory_kb_default 50 * 1024 13 | @timeout_ms_default 5000 14 | @check_every_ms_default 20 15 | @bytes_in_kb 1024 16 | 17 | @enforce_keys [:pid, :bindings] 18 | defstruct [:pid, :bindings] 19 | 20 | @doc """ 21 | Initialize and returns a process where Elixir code will run in "sandbox mode". 22 | This is useful if we want to provide the chance to more than one individual 23 | command, where the user can assume it is always working the same process (as 24 | it happens when someone runs different commands in iex). 25 | 26 | Returns a Sandbox struct including the dedicated process and an empty list of 27 | bindings. 28 | """ 29 | @spec init() :: sandbox() 30 | def init() do 31 | loop = fn loop_func -> 32 | receive do 33 | {:command, command, bindings, parent_pid} -> 34 | result = CodeExecutor.execute_code(command, bindings) 35 | send(parent_pid, {:result, result}) 36 | 37 | loop_func.(loop_func) 38 | end 39 | end 40 | 41 | creator_pid = self() 42 | 43 | pid = 44 | spawn(fn -> 45 | # Add some metadata to those process to identify them, allowing to further 46 | # analysis 47 | Process.put(:sandbox_owner, creator_pid) 48 | loop.(loop) 49 | end) 50 | 51 | %__MODULE__{pid: pid, bindings: []} 52 | end 53 | 54 | @doc """ 55 | Executes a command (Elixir code in a string) in the process given by the 56 | Sandbox struct provided. 57 | 58 | Returns the result of the execution and a Sandbox struct with the changes in 59 | the bindings, if the command succeeded. In case of errors, it returns an 60 | `{:error, error_message}` tuple where the second element is a string with an 61 | explanation. 62 | 63 | If the execution takes more time than the specified timeout, an error is 64 | returned. In addition, if the execution uses more memory than the allowed 65 | amount, it is interrupted and an error is returned. 66 | 67 | You can use the following options in the `opts` argument: 68 | 69 | `timeout`: Time limit to run the command (in milliseconds). The default is 70 | 5000. 71 | 72 | `max_memory_kb`: Memory usage limit (expressed in Kb). The default is 73 | 30. 74 | 75 | `check_every`: Determine the time elapsed between checks where memory 76 | usage is measured (expressed in Kb). The default is 20. 77 | """ 78 | @typep execution_result() :: {binary(), sandbox()} 79 | @spec execute(binary(), sandbox(), keyword()) :: 80 | {:success, execution_result()} | {:error, execution_result()} 81 | def execute(command, sandbox, opts \\ []) 82 | 83 | def execute(command, sandbox, _) when byte_size(command) > @max_command_length do 84 | {:error, {"Command is too long. Try running a shorter piece of code.", sandbox}} 85 | end 86 | 87 | def execute(command, sandbox, opts) do 88 | task = Task.async(fn -> do_execute(command, sandbox, opts) end) 89 | Task.await(task, :infinity) 90 | end 91 | 92 | defp do_execute(command, sandbox, opts) do 93 | send(sandbox.pid, {:command, command, sandbox.bindings, self()}) 94 | 95 | case check_execution_status(sandbox.pid, normalize_options(opts)) do 96 | {:ok, {:success, {result, bindings}}} -> 97 | {:success, {result, %{sandbox | bindings: Enum.sort(bindings)}}} 98 | 99 | {:ok, {:error, result}} -> 100 | {:error, {result, sandbox}} 101 | 102 | :timeout -> 103 | {:error, {"The command was cancelled due to timeout", restore(sandbox)}} 104 | 105 | :memory_abuse -> 106 | {:error, {"The command used more memory than allowed", restore(sandbox)}} 107 | end 108 | end 109 | 110 | @doc """ 111 | The sandbox process is exited. This function should be used when the sandbox 112 | is not longer needed so resources are properly disposed. 113 | """ 114 | def terminate(%__MODULE__{pid: pid}) do 115 | Process.exit(pid, :kill) 116 | end 117 | 118 | defp normalize_options(opts) do 119 | timeout = Keyword.get(opts, :timeout, @timeout_ms_default) 120 | check_every = Keyword.get(opts, :check_every, @check_every_ms_default) 121 | ticks = floor(timeout / check_every) 122 | 123 | max_memory_kb = Keyword.get(opts, :max_memory_kb, @max_memory_kb_default) * @bytes_in_kb 124 | 125 | max_binary_memory_kb = 126 | Keyword.get(opts, :max_binary_memory_kb, @max_binary_memory_kb_default) * @bytes_in_kb 127 | 128 | [ 129 | ticks: ticks, 130 | check_every: check_every, 131 | max_memory_kb: max_memory_kb, 132 | max_binary_memory_kb: max_binary_memory_kb 133 | ] 134 | end 135 | 136 | defp restore(sandbox) do 137 | %__MODULE__{sandbox | pid: init().pid} 138 | end 139 | 140 | defp check_execution_status(pid, [{:ticks, 0} | _]) do 141 | Process.exit(pid, :kill) 142 | :timeout 143 | end 144 | 145 | defp check_execution_status( 146 | pid, 147 | [ 148 | ticks: ticks, 149 | check_every: check_every, 150 | max_memory_kb: max_memory_kb, 151 | max_binary_memory_kb: max_binary_memory_kb 152 | ] = opts 153 | ) do 154 | receive do 155 | {:result, result} -> 156 | {:ok, result} 157 | after 158 | check_every -> 159 | if allowed_memory_usage_by_process?(pid, max_memory_kb) and 160 | allowed_memory_usage_in_binaries?(max_binary_memory_kb) do 161 | check_execution_status(pid, Keyword.put(opts, :ticks, ticks - 1)) 162 | else 163 | Process.exit(pid, :kill) 164 | :memory_abuse 165 | end 166 | end 167 | end 168 | 169 | defp allowed_memory_usage_by_process?(pid, memory_limit) do 170 | {:memory, memory} = Process.info(pid, :memory) 171 | memory <= memory_limit 172 | end 173 | 174 | defp allowed_memory_usage_in_binaries?(binaries_memory_limit) do 175 | :erlang.memory(:binary) <= binaries_memory_limit 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox/allowed_elixir_modules.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox.AllowedElixirModules do 2 | @moduledoc """ 3 | Analyze the AST to filter out non white-listed modules and kernel functions 4 | """ 5 | 6 | alias ElixirConsole.Sandbox.CommandValidator 7 | @behaviour CommandValidator 8 | 9 | @valid_modules ElixirConsole.ElixirSafeParts.safe_modules() 10 | 11 | @impl CommandValidator 12 | def validate(ast) do 13 | {_ast, result} = Macro.prewalk(ast, [], &valid?(&1, &2)) 14 | 15 | result 16 | |> Enum.filter(&match?({:error, _}, &1)) 17 | |> Enum.map(fn {:error, module} -> module end) 18 | |> Enum.dedup() 19 | |> case do 20 | [] -> 21 | :ok 22 | 23 | invalid_modules -> 24 | {:error, 25 | "Some Elixir modules are not allowed to be used. " <> 26 | "Not allowed modules attempted: #{inspect(invalid_modules)}"} 27 | end 28 | end 29 | 30 | defp valid?({:__aliases__, _, [module]} = elem, acc) when module not in @valid_modules do 31 | {elem, [{:error, module} | acc]} 32 | end 33 | 34 | defp valid?(elem, acc), do: {elem, [:ok | acc]} 35 | end 36 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox/code_executor.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox.CodeExecutor do 2 | @moduledoc """ 3 | This module is responsible to orchestrate the validation and execution of the 4 | user-provided code. It should be run inside the sandbox process. 5 | """ 6 | 7 | require Logger 8 | 9 | alias ElixirConsole.Sandbox.{CommandValidator, RuntimeValidations} 10 | 11 | def execute_code(command, bindings) do 12 | Logger.info("Command to be executed: #{command}") 13 | 14 | try do 15 | with :ok <- CommandValidator.safe_command?(command), 16 | command_ast <- RuntimeValidations.get_augmented_ast(command), 17 | {result, bindings} <- 18 | Code.eval_quoted(command_ast, bindings, eval_context()) do 19 | {:success, {result, bindings}} 20 | else 21 | error -> error 22 | end 23 | rescue 24 | exception -> 25 | {:error, inspect(exception)} 26 | end 27 | end 28 | 29 | # This is just to make available Bitwise functions when evaluating user code 30 | defp eval_context do 31 | [ 32 | requires: [Kernel], 33 | functions: [ 34 | {Kernel, __ENV__.functions[Kernel]}, 35 | {Bitwise, 36 | [ 37 | &&&: 2, 38 | <<<: 2, 39 | >>>: 2, 40 | ^^^: 2, 41 | band: 2, 42 | bnot: 1, 43 | bor: 2, 44 | bsl: 2, 45 | bsr: 2, 46 | bxor: 2, 47 | |||: 2, 48 | ~~~: 1 49 | ]} 50 | ] 51 | ] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox/command_validator.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox.CommandValidator do 2 | @moduledoc """ 3 | Check if a given Elixir code from untrusted sources is safe to be executed in 4 | the sandbox. This module also defines a behavior to be implemented by 5 | validator modules, providing a mechanism to compose individual safety checks 6 | over the command. 7 | """ 8 | 9 | @type ast :: Macro.t() 10 | @callback validate(ast()) :: :ok | {:error, String.t()} 11 | 12 | alias ElixirConsole.Sandbox.{ 13 | AllowedElixirModules, 14 | ErlangModulesAbsence, 15 | ExcludeConversionToAtoms, 16 | SafeKernelFunctions 17 | } 18 | 19 | @ast_validator_modules [ 20 | AllowedElixirModules, 21 | ErlangModulesAbsence, 22 | ExcludeConversionToAtoms, 23 | SafeKernelFunctions 24 | ] 25 | 26 | def safe_command?(command) do 27 | ast = Code.string_to_quoted!(command) 28 | 29 | Enum.reduce_while(@ast_validator_modules, nil, fn module, _acc -> 30 | case apply(module, :validate, [ast]) do 31 | :ok -> {:cont, :ok} 32 | error -> {:halt, error} 33 | end 34 | end) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox/erlang_modules_absence.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox.ErlangModulesAbsence do 2 | @moduledoc """ 3 | Analyze the AST and check if Erlang modules are not present. 4 | """ 5 | 6 | alias ElixirConsole.Sandbox.{CommandValidator, Util} 7 | @behaviour CommandValidator 8 | 9 | @impl CommandValidator 10 | def validate(ast) do 11 | {_ast, result} = Macro.prewalk(ast, [], &valid?(&1, &2)) 12 | 13 | result 14 | |> Enum.filter(&match?({:error, _}, &1)) 15 | |> Enum.map(fn {:error, module} -> module end) 16 | |> Enum.dedup() 17 | |> case do 18 | [] -> 19 | :ok 20 | 21 | invalid_modules -> 22 | {:error, 23 | "Non-Elixir modules are not allowed to be used. " <> 24 | "Not allowed modules attempted: #{inspect(invalid_modules)}"} 25 | end 26 | end 27 | 28 | defp valid?({:., _, [module, _]} = elem, acc) do 29 | if Util.is_erlang_module?(module) do 30 | {elem, [{:error, module} | acc]} 31 | else 32 | {elem, acc} 33 | end 34 | end 35 | 36 | defp valid?(elem, acc), do: {elem, acc} 37 | end 38 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox/exclude_conversion_to_atoms.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox.ExcludeConversionToAtoms do 2 | @moduledoc """ 3 | Check if the command from untrusted source is free of calls that 4 | programmatically create atoms. 5 | """ 6 | 7 | alias ElixirConsole.Sandbox.CommandValidator 8 | @behaviour CommandValidator 9 | 10 | @impl CommandValidator 11 | def validate(ast) do 12 | {_ast, result} = Macro.prewalk(ast, :ok, &valid?(&1, &2)) 13 | 14 | case result do 15 | :to_atom_error -> 16 | {:error, 17 | "Programmatically creation of atoms is not allowed in this online console. " <> 18 | "Consider using String.to_existing_atom/1"} 19 | 20 | :atom_modifier_in_sigils -> 21 | {:error, 22 | "Programmatically creation of atoms is not allowed in this online console. " <> 23 | "For this reason, the `a` modifier is not allowed when using ~w. " <> 24 | "Instead, try using ~W since it does not interpolate the content"} 25 | 26 | :ok -> 27 | :ok 28 | end 29 | end 30 | 31 | defp valid?(elem, result) when result != :ok, do: {elem, result} 32 | 33 | defp valid?({:., _, [{:__aliases__, _, [module]}, function]} = elem, _acc) 34 | when function == :to_atom and module in [:String, :List] do 35 | {elem, :to_atom_error} 36 | end 37 | 38 | defp valid?({:sigil_w, _, [_, 'a']} = elem, _acc) do 39 | {elem, :atom_modifier_in_sigils} 40 | end 41 | 42 | defp valid?(elem, acc), do: {elem, acc} 43 | end 44 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox/runtime_validations.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox.RuntimeValidations do 2 | @moduledoc """ 3 | This module injects code to the AST for code from untrusted sources. It 4 | ensures that non-secure functions are not invoked at runtime. 5 | """ 6 | 7 | alias ElixirConsole.{ElixirSafeParts, Sandbox.Util} 8 | 9 | @this_module __MODULE__ |> Module.split() |> Enum.map(&String.to_atom/1) 10 | @valid_modules ElixirSafeParts.safe_elixir_modules() 11 | @kernel_functions_blacklist ElixirSafeParts.unsafe_kernel_functions() 12 | 13 | @max_string_duplication_times 100_000 14 | 15 | # Random string that is large enough so it does not collide with user's code 16 | @keep_dot_operator_mark Util.random_atom(64) 17 | 18 | @doc """ 19 | Returns the AST for the given Elixir code but including invocations to 20 | `safe_invocation/2` before invoking any given function (based on the 21 | presence of the :. operator) 22 | """ 23 | def get_augmented_ast(command) do 24 | ast = Code.string_to_quoted!(command) 25 | 26 | {augmented_ast, _result} = 27 | Macro.traverse(ast, nil, &add_safe_invocation(&1, &2), &restore_dot_operator(&1, &2)) 28 | 29 | augmented_ast 30 | end 31 | 32 | # Do not inline a `safe_invocation` call when the dot operator is used to access 33 | # nested structures 34 | defp add_safe_invocation({{:., _, [{{:., _, [Access, :get]}, _, _}, _]}, _, _} = elem, acc), 35 | do: {elem, acc} 36 | 37 | defp add_safe_invocation({{:., _, [Access, :get]}, _, _} = elem, acc), do: {elem, acc} 38 | 39 | # Do not inline a `safe_invocation` call when the dot operator is used in 40 | # function capturing. Note it is using the special token. 41 | # @keep_dot_operator_mark to prevent the modification of the inner node with 42 | # the dot operator. restore_dot_operator/2 is responsible for restoring the 43 | # AST by replacing token occurrences with the :. atom 44 | defp add_safe_invocation( 45 | {:&, outer_meta, 46 | [ 47 | {:/, middle_meta, 48 | [ 49 | {{:., inner_meta, [{:__aliases__, meta, [module]}, function]}, inner_params, 50 | outer_params}, 51 | arity 52 | ]} 53 | ]}, 54 | acc 55 | ) 56 | when is_atom(module) and is_atom(function) and is_integer(arity) do 57 | elem = 58 | {:&, outer_meta, 59 | [ 60 | {:/, middle_meta, 61 | [ 62 | {{@keep_dot_operator_mark, inner_meta, [{:__aliases__, meta, [module]}, function]}, 63 | inner_params, outer_params}, 64 | arity 65 | ]} 66 | ]} 67 | 68 | {elem, acc} 69 | end 70 | 71 | # Inline `safe_invocation` call when dot operator implies to invoke a function 72 | # (this is exactly the case we want to intercept) 73 | defp add_safe_invocation({{:., meta, [callee, function]}, outer_meta, params}, acc) 74 | when is_atom(callee) or is_tuple(callee) do 75 | elem = 76 | {{:., outer_meta, 77 | [ 78 | {:__aliases__, meta, @this_module}, 79 | :safe_invocation 80 | ]}, meta, [callee, function, params]} 81 | 82 | {elem, acc} 83 | end 84 | 85 | # Also inline a `safe_invocation` call when pipe operator is used, since it is 86 | # equivalent to direct invocation 87 | defp add_safe_invocation( 88 | {:|>, outer_meta, 89 | [first_param, {{:., meta, [callee, function]}, _more_meta, remaining_params}]}, 90 | acc 91 | ) 92 | when is_atom(callee) or is_tuple(callee) do 93 | params = [first_param | remaining_params] 94 | 95 | elem = 96 | {{:., outer_meta, 97 | [ 98 | {:__aliases__, meta, @this_module}, 99 | :safe_invocation 100 | ]}, meta, [callee, function, params]} 101 | 102 | {elem, acc} 103 | end 104 | 105 | defp add_safe_invocation(elem, acc), do: {elem, acc} 106 | 107 | # Note that add_safe_invocation/2 could interchange a dot operator by an 108 | # special token (@keep_dot_operator_mark) in cases where the dot operator does 109 | # not represents an invocation (situation that is determined by the parent 110 | # nodes). This function restores the original :. occurrences. 111 | defp restore_dot_operator( 112 | {{@keep_dot_operator_mark, meta, [callee, function]}, outer_meta, params}, 113 | acc 114 | ) do 115 | elem = {{:., meta, [callee, function]}, outer_meta, params} 116 | 117 | {elem, acc} 118 | end 119 | 120 | defp restore_dot_operator(elem, acc), do: {elem, acc} 121 | 122 | @doc """ 123 | This function is meant to be injected into the modified AST so we have safe 124 | invocations. The original invocation is done once it is validated as a 125 | secure call. 126 | """ 127 | def safe_invocation(callee, _, _) when is_atom(callee) and callee not in @valid_modules do 128 | raise "Sandbox runtime error: Some Elixir modules are not allowed to be used. " <> 129 | "Not allowed module attempted: #{inspect(callee)}" 130 | end 131 | 132 | def safe_invocation(Kernel, function, _) when function in @kernel_functions_blacklist do 133 | raise "Sandbox runtime error: Some Kernel functions/macros are not allowed to be used. " <> 134 | "Not allowed function/macro attempted: #{inspect(function)}" 135 | end 136 | 137 | def safe_invocation(String, :to_atom, _) do 138 | raise "Sandbox runtime error: String.to_atom/1 is not allowed." 139 | end 140 | 141 | def safe_invocation(String, :duplicate, [_, times]) 142 | when times >= @max_string_duplication_times do 143 | raise "Sandbox runtime error: String.duplicate/2 is not safe to be executed " <> 144 | "when the second parameter is a very large number." 145 | end 146 | 147 | def safe_invocation(String, function, [_, times, _]) 148 | when times >= @max_string_duplication_times and function in [:pad_leading, :pad_trailing] do 149 | raise "Sandbox runtime error: String.#{function}/3 is not safe to be executed " <> 150 | "when the second parameter is a very large number." 151 | end 152 | 153 | # This approach is not working well with Kernel macros that are invoked with 154 | # explicit callee (e.g. Kernel.to_string/1). The following functions cover a 155 | # little portion of the existing cases. Future work can be done to transform 156 | # AST converting those macro calls to implicit invocations. 157 | 158 | # In particular, Kernel.to_string/1 must be supported because of string 159 | # interpolation. This is the only one that is very important to have done. 160 | def safe_invocation(Kernel, :to_string, [param]) do 161 | to_string(param) 162 | end 163 | 164 | # A few extra cases that are cheap to cover 165 | def safe_invocation(Kernel, :binding, params) do 166 | apply(&binding(&1), params) 167 | end 168 | 169 | def safe_invocation(Kernel, :is_nil, [param]) do 170 | is_nil(param) 171 | end 172 | 173 | def safe_invocation(Kernel, :to_charlist, [param]) do 174 | to_charlist(param) 175 | end 176 | 177 | # Integer macros 178 | require Integer 179 | 180 | def safe_invocation(Integer, :is_odd, param) do 181 | Integer.is_odd(param) 182 | end 183 | 184 | def safe_invocation(Integer, :is_even, param) do 185 | Integer.is_even(param) 186 | end 187 | 188 | # Case where dot operator is used as a way to access a nested structure We are 189 | # still adding the safe_invocation call, just to make sure the callee is not 190 | # an atom but we can not resolve it using Kernel.apply/3 191 | def safe_invocation(term, key, []) when is_map(term) and is_atom(key) do 192 | case Access.fetch(term, key) do 193 | {:ok, result} -> result 194 | :error -> %KeyError{key: key, message: nil, term: term} 195 | end 196 | end 197 | 198 | # Base case 199 | def safe_invocation(callee, function, params) when is_atom(callee) do 200 | apply(callee, function, params) 201 | end 202 | 203 | def safe_invocation(callee, function, params) do 204 | try do 205 | Sentry.capture_message("safe_invocation unexpected case", 206 | extra: %{callee: callee, function: function, params: params} 207 | ) 208 | after 209 | raise "Internal error. Please fill an issue at https://github.com/wyeworks/elixir_console/issues." 210 | end 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox/safe_kernel_functions.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox.SafeKernelFunctions do 2 | @moduledoc """ 3 | Check if a command from untrusted source is using only safe Kernel functions 4 | """ 5 | 6 | alias ElixirConsole.Sandbox.CommandValidator 7 | @behaviour CommandValidator 8 | 9 | @kernel_functions_blacklist ElixirConsole.ElixirSafeParts.unsafe_kernel_functions() 10 | 11 | @impl CommandValidator 12 | def validate(ast) do 13 | {_ast, result} = Macro.prewalk(ast, [], &valid?(&1, &2)) 14 | 15 | result 16 | |> Enum.filter(&match?({:error, _}, &1)) 17 | |> Enum.map(fn {:error, function} -> function end) 18 | |> Enum.dedup() 19 | |> case do 20 | [] -> 21 | :ok 22 | 23 | unsafe_functions -> 24 | {:error, 25 | "Only safe Kernel functions are allowed to be used. " <> 26 | "Not allowed functions attempted: #{inspect(unsafe_functions)}"} 27 | end 28 | end 29 | 30 | defp valid?({function, _, _} = elem, acc) when function in @kernel_functions_blacklist do 31 | {elem, [{:error, function} | acc]} 32 | end 33 | 34 | defp valid?({:., _, [{:__aliases__, _, [:Kernel]}, function]} = elem, acc) 35 | when function in @kernel_functions_blacklist do 36 | {elem, [{:error, function} | acc]} 37 | end 38 | 39 | defp valid?(elem, acc), do: {elem, acc} 40 | end 41 | -------------------------------------------------------------------------------- /lib/elixir_console/sandbox/util.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsole.Sandbox.Util do 2 | @moduledoc false 3 | 4 | @az_range 97..122 5 | 6 | def is_erlang_module?(module) when not is_atom(module), do: false 7 | 8 | def is_erlang_module?(module) do 9 | module 10 | |> to_charlist 11 | |> starts_with_lowercase? 12 | end 13 | 14 | # Based on https://stackoverflow.com/a/32002566/2819826 15 | def random_atom(length) do 16 | :crypto.strong_rand_bytes(length) 17 | |> Base.url_encode64() 18 | |> binary_part(0, length) 19 | |> String.to_atom() 20 | end 21 | 22 | defp starts_with_lowercase?([first_char | _]) when first_char in @az_range, do: true 23 | defp starts_with_lowercase?(_module_charlist), do: false 24 | end 25 | -------------------------------------------------------------------------------- /lib/elixir_console_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsoleWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use ElixirConsoleWeb, :controller 9 | use ElixirConsoleWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: ElixirConsoleWeb 23 | 24 | import Plug.Conn 25 | alias ElixirConsoleWeb.Router.Helpers, as: Routes 26 | end 27 | end 28 | 29 | def view do 30 | quote do 31 | use Phoenix.View, 32 | root: "lib/elixir_console_web/templates", 33 | namespace: ElixirConsoleWeb 34 | 35 | # Import convenience functions from controllers 36 | import Phoenix.Controller, 37 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 38 | 39 | # Include shared imports and aliases for views 40 | unquote(view_helpers()) 41 | end 42 | end 43 | 44 | def live_view do 45 | quote do 46 | use Phoenix.LiveView, layout: {ElixirConsoleWeb.LayoutView, "live.html"} 47 | 48 | unquote(view_helpers()) 49 | end 50 | end 51 | 52 | def live_component do 53 | quote do 54 | use Phoenix.LiveComponent 55 | 56 | unquote(view_helpers()) 57 | end 58 | end 59 | 60 | def router do 61 | quote do 62 | use Phoenix.Router 63 | 64 | import Plug.Conn 65 | import Phoenix.Controller 66 | import Phoenix.LiveView.Router 67 | 68 | use Plug.ErrorHandler 69 | use Sentry.Plug 70 | end 71 | end 72 | 73 | defp view_helpers do 74 | quote do 75 | # Use all HTML functionality (forms, tags, etc) 76 | use Phoenix.HTML 77 | 78 | # Import LiveView helpers (live_render, live_component, live_patch, etc) 79 | import Phoenix.LiveView.Helpers 80 | 81 | # Import basic rendering functionality (render, render_layout, etc) 82 | import Phoenix.View 83 | 84 | alias ElixirConsoleWeb.Router.Helpers, as: Routes 85 | end 86 | end 87 | 88 | @doc """ 89 | When used, dispatch to the appropriate controller/view/etc. 90 | """ 91 | defmacro __using__(which) when is_atom(which) do 92 | apply(__MODULE__, which, []) 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/elixir_console_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsoleWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :elixir_console 3 | use Sentry.Phoenix.Endpoint 4 | 5 | # The session will be stored in the cookie and signed, 6 | # this means its contents can be read but not tampered with. 7 | # Set :encryption_salt if you would also like to encrypt it. 8 | @session_options [ 9 | store: :cookie, 10 | key: "_elixir_console_key", 11 | signing_salt: "pIQrFsE9x" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 15 | 16 | # Serve at "/" the static files from "priv/static" directory. 17 | # 18 | # You should set gzip to true if you are running phx.digest 19 | # when deploying your static files in production. 20 | plug Plug.Static, 21 | at: "/", 22 | from: :elixir_console, 23 | gzip: false, 24 | only: ~w(assets fonts images favicon.ico robots.txt) 25 | 26 | # ~w(css fonts images js favicon.ico robots.txt) 27 | 28 | # Code reloading can be explicitly enabled under the 29 | # :code_reloader configuration of your endpoint. 30 | if code_reloading? do 31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 32 | plug Phoenix.LiveReloader 33 | plug Phoenix.CodeReloader 34 | end 35 | 36 | plug Plug.RequestId 37 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 38 | 39 | plug Plug.Parsers, 40 | parsers: [:urlencoded, :multipart, :json], 41 | pass: ["*/*"], 42 | json_decoder: Phoenix.json_library() 43 | 44 | plug Plug.MethodOverride 45 | plug Plug.Head 46 | plug Plug.Session, @session_options 47 | 48 | plug ElixirConsoleWeb.HerokuRedirect 49 | plug ElixirConsoleWeb.Router 50 | end 51 | -------------------------------------------------------------------------------- /lib/elixir_console_web/live/console_live.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsoleWeb.ConsoleLive do 2 | @moduledoc """ 3 | This is the live view component that implements the console UI. 4 | """ 5 | 6 | use ElixirConsoleWeb, :live_view 7 | 8 | alias ElixirConsole.Sandbox 9 | alias ElixirConsoleWeb.LiveMonitor 10 | alias ElixirConsoleWeb.ConsoleLive.{CommandInputComponent, HistoryComponent, SidebarComponent} 11 | 12 | defmodule Output do 13 | @enforce_keys [:command, :id] 14 | defstruct [:command, :result, :error, :id] 15 | end 16 | 17 | @impl true 18 | def mount(_params, _session, socket) do 19 | sandbox = Sandbox.init() 20 | LiveMonitor.monitor(self(), __MODULE__, %{id: socket.id, sandbox: sandbox}) 21 | 22 | {:ok, 23 | assign( 24 | socket, 25 | output: [], 26 | history: [], 27 | suggestions: [], 28 | contextual_help: nil, 29 | command_id: 0, 30 | sandbox: sandbox 31 | )} 32 | end 33 | 34 | @doc "Function invoked when the live view process is finished. See LiveMonitor.terminate/1." 35 | def unmount(%{sandbox: sandbox}) do 36 | Sandbox.terminate(sandbox) 37 | end 38 | 39 | @impl true 40 | def render(assigns) do 41 | ~H""" 42 |
Elixir <%= System.version() %>/OTP <%= System.otp_release() %>
18 |<%= inspect(value) %>
No bindings yet!
40 | <% end %>
41 | [UP] [DOWN]: Navigate through commands history
73 |[TAB]: Get suggestions or autocomplete while typing
74 |You can see the history panel that includes all your commands and their output. 75 | Click on any Elixir function to see here the corresponding documentation.
76 |Please note some features of the language are not safe to run in a shared environment like this console. 78 | If you are interested in knowing more about the limitations, you must read here.
79 |Please report any security vulnerabilities to 80 | elixir-console-security@wyeworks.com. 81 |
82 | """ 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/elixir_console_web/live/live_monitor.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsoleWeb.LiveMonitor do 2 | @moduledoc """ 3 | This module monitors the created sandbox processes. Gives a way to dispose 4 | those processes when they are not longer used. 5 | 6 | The code is based on https://github.com/phoenixframework/phoenix_live_view/issues/123 7 | """ 8 | 9 | use GenServer 10 | 11 | def start_link(_) do 12 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__) 13 | end 14 | 15 | def monitor(pid, view_module, meta) do 16 | GenServer.call(__MODULE__, {:monitor, pid, view_module, meta}) 17 | end 18 | 19 | def update_sandbox(pid, view_module, meta) do 20 | GenServer.call(__MODULE__, {:update_sandbox, pid, view_module, meta}) 21 | end 22 | 23 | def init(_) do 24 | {:ok, %{views: %{}}} 25 | end 26 | 27 | def handle_call({:monitor, pid, view_module, meta}, _from, %{views: views} = state) do 28 | Process.monitor(pid) 29 | {:reply, :ok, %{state | views: Map.put(views, pid, {view_module, meta})}} 30 | end 31 | 32 | def handle_call({:update_sandbox, pid, view_module, meta}, _from, %{views: views} = state) do 33 | {:reply, :ok, %{state | views: Map.put(views, pid, {view_module, meta})}} 34 | end 35 | 36 | def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do 37 | {{module, meta}, new_views} = Map.pop(state.views, pid) 38 | module.unmount(meta) 39 | {:noreply, %{state | views: new_views}} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/elixir_console_web/plugs/heroku_redirect.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsoleWeb.HerokuRedirect do 2 | @moduledoc """ 3 | Redirect to our custom domain if the app is accessed using the Heroku domain 4 | """ 5 | 6 | import Plug.Conn 7 | 8 | def init(default), do: default 9 | 10 | def call(conn, _default) do 11 | if configured_host() != conn.host do 12 | conn 13 | |> put_resp_header("location", redirect_url()) 14 | |> send_resp(:moved_permanently, "") 15 | |> halt() 16 | else 17 | conn 18 | end 19 | end 20 | 21 | defp redirect_url do 22 | URI.to_string(%URI{ 23 | host: configured_host(), 24 | scheme: configured_scheme() 25 | }) 26 | end 27 | 28 | defp configured_host, do: get_from_configured_url(:host, "localhost") 29 | defp configured_scheme, do: get_from_configured_url(:scheme, "https") 30 | 31 | defp get_from_configured_url(key, default) do 32 | Keyword.get(ElixirConsoleWeb.Endpoint.config(:url), key, default) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/elixir_console_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ElixirConsoleWeb.Router do 2 | use ElixirConsoleWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {ElixirConsoleWeb.LayoutView, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", ElixirConsoleWeb do 18 | pipe_through :browser 19 | 20 | live "/", ConsoleLive 21 | end 22 | 23 | # Other scopes may use custom stacks. 24 | # scope "/api", ElixirConsoleWeb do 25 | # pipe_through :api 26 | # end 27 | end 28 | -------------------------------------------------------------------------------- /lib/elixir_console_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |<%= live_flash(@flash, :info) %>
5 | 6 |<%= live_flash(@flash, :error) %>
9 | 10 |