[leiningen] Leiningen transport based repl authorization (foundation)

  • From: Rob Browning <rlb@xxxxxxxxxxxxxxxx>
  • To: leiningen@xxxxxxxxxxxxx
  • Date: Sun, 07 Jan 2018 14:10:04 -0600


I've been working on adding some form of authorization to nrepl --
right now there is none, so whenever you're running an nrepl, any
account on the same machine can do anything it wants to your account
with a simple curl command (i.e. erase your home directory, etc.).

I've come up with an approach that works, and I'd like to see if you
think it seems viable, and assuming everything else works out, if the
proposed changes to Leiningen itself seem reasonble.

The authorization operates at the nrepl transport level, which means
that it's invisible to the message handler(s) that you can specify with
:repl-options :nrepl-handler or :nrepl-middleware.

The general idea is that every incoming message must include an :auth
token that matches an expected value, or it will be rejected, i.e.

  {:op "clone"
   :auth "i77kW6vf5q7GEhoIuWBx/RmHg4iuT3vvofqBESGYFa27"
   ...}

The :auth field is added by the transport on the way out and removed
after validation on the way in.

Please ignore for the moment, how the token is created or found -- I
have a working proposal for that too, but before that matters,
Leiningen, REPL-y, and tools.nrepl must fully support custom transports.
(They almost do now.)

Toward that end, here are an annotated set of changes to the three
projects that will allow the authorization scheme to work.  Given them,
you can enable authorization by adding this to any project.clj:

  :plugins [[lein-token-auth "0.1.0-SNAPSHOT"]]
  :injections [(require '[leiningen.token-auth])]
  :repl-options {:nrepl-transport-fn 
leiningen.token-auth/token-authorized-transport}


For reply, we need to change get-connection to allow a transport-fn
argument.  When transport-fn is not specified, then it should behave as
before:

  diff --git a/src/clj/reply/eval_modes/nrepl.clj 
b/src/clj/reply/eval_modes/nrepl.clj
  index e8f7d3e..a0fd6d6 100644
  --- a/src/clj/reply/eval_modes/nrepl.clj
  +++ b/src/clj/reply/eval_modes/nrepl.clj
  @@ -152,19 +152,25 @@
       (require '[cemerick.drawbridge.client])
       (catch Exception e)))

  -(defn get-connection [{:keys [attach host port]}]
  +(defn get-connection [{:keys [attach host port transport-fn]}]
     (let [server (when-not attach
                    (nrepl.server/start-server
  -                   :port (Integer/parseInt (str (or port 0)))))
  +                  :port (Integer/parseInt (str (or port 0)))
  +                  :transport-fn transport-fn))
           port (when-not attach
                  (let [^ServerSocket socket (-> server deref :ss)]
                    (.getLocalPort socket)))
           url (url-for attach host port)]
       (when server
         (reset! nrepl-server server))
  -    (when (-> url java.net.URI. .getScheme .toLowerCase #{"http" "https"})
  -      (load-drawbridge))
  -    (nrepl/url-connect url)))
  +    (let [uri (-> url java.net.URI.)]
  +      (when (-> uri .getScheme .toLowerCase #{"http" "https"})
  +        (load-drawbridge))
  +      (if transport-fn
  +        (nrepl/connect :host (.getHost uri)
  +                       :port (.getPort uri)
  +                       :transport-fn transport-fn)
  +        (nrepl/url-connect url)))))

   (defn completion-eval [client session form]
     (let [results (atom "nil")]


We need tools.nrepl start-server to use a specified transport-fn for
both the server *and* the ack, so we augment send-ack to handle an
optional transport-fn argument and then specify it in start-server:

  diff --git a/src/main/clojure/clojure/tools/nrepl/ack.clj 
b/src/main/clojure/clojure/tools/nrepl/ack.clj
  index 60b51ff..7bc6b74 100644
  --- a/src/main/clojure/clojure/tools/nrepl/ack.clj
  +++ b/src/main/clojure/clojure/tools/nrepl/ack.clj
  @@ -43,10 +43,15 @@

   ; TODO could stand to have some better error handling around all of this
   (defn send-ack
  -  [my-port ack-port]
  -  (with-open [^java.io.Closeable transport (repl/connect :port ack-port)]
  -    (let [client (repl/client transport 1000)]
  -      ; consume response from the server, solely to let that side
  -      ; finish cleanly without (by default) spewing a SocketException when
  -      ; the ack client goes away suddenly
  -      (dorun (repl/message client {:op :ack :port my-port})))))
  +  ([my-port ack-port]
  +   (send-ack my-port ack-port nil))
  +  ([my-port ack-port transport-fn]
  +   (with-open [^java.io.Closeable transport (if transport-fn
  +                                              (repl/connect :port ack-port
  +                                                            :transport-fn 
transport-fn)
  +                                              (repl/connect :port ack-port))]
  +     (let [client (repl/client transport 1000)]
  +       ;; consume response from the server, solely to let that side
  +       ;; finish cleanly without (by default) spewing a SocketException when
  +       ;; the ack client goes away suddenly
  +       (dorun (repl/message client {:op :ack :port my-port}))))))
  diff --git a/src/main/clojure/clojure/tools/nrepl/server.clj 
b/src/main/clojure/clojure/tools/nrepl/server.clj
  index 3284c98..ad321c1 100644
  --- a/src/main/clojure/clojure/tools/nrepl/server.clj
  +++ b/src/main/clojure/clojure/tools/nrepl/server.clj
  @@ -154,5 +154,5 @@
                    :ss ss)]
       (future (accept-connection server))
       (when ack-port
  -      (ack/send-ack (:port server) ack-port))
  +      (ack/send-ack (:port server) ack-port transport-fn))
       server))


Finally, we change Leiningen to support a :repl-options
:nrepl-transport-fn option, and to use that for all communications,
including the ack-server.  Of course, the approach below only works if
there's never going to be more than one nrepl-transport-fn active per
JVM.  I'd be happy to fix that if we need to, and know what we want:

  diff --git a/src/leiningen/repl.clj b/src/leiningen/repl.clj
  index 8f6dc6cf..4e82dac8 100644
  --- a/src/leiningen/repl.clj
  +++ b/src/leiningen/repl.clj
  @@ -109,7 +109,8 @@
                             port {:port port}
                             :else {}))
           (clojure.set/rename-keys opts {:prompt :custom-prompt
  -                                       :welcome :custom-help})
  +                                       :welcome :custom-help
  +                                       :nrepl-transport-fn :transport-fn})
           (if (:port opts) (update-in opts [:port] str) opts)))

   (defn init-ns [{{:keys [init-ns]} :repl-options, :keys [main]}]
  @@ -172,7 +173,9 @@
     [`(let [server# (clojure.tools.nrepl.server/start-server
                      :bind ~(:host cfg) :port ~(:port cfg)
                      :ack-port ~ack-port
  -                   :handler ~(handler-for project))
  +                   :handler ~(handler-for project)
  +                   :transport-fn ~(get-in project
  +                                          [:repl-options 
:nrepl-transport-fn]))
             port# (:port server#)
             repl-port-file# (apply io/file ~(repl-port-file-vector project))
             ;; TODO 3.0: remove legacy repl port support.
  @@ -218,10 +221,26 @@
        `(do (try (require '~(init-ns project)) (catch Exception t#))
             (require ~@(init-requires project 'reply.main))))))

  +;; (def ack-transport (delay (eval '(do (require '[leiningen.auth])
  +;;                                      
leiningen.auth/token-authorized-transport))))
  +
  +(def ack-transport (promise))
  +
  +(defn configure-ack-transport [project]
  +  (locking ack-transport
  +    (if (realized? ack-transport)
  +      (when-not (= (get-in project [:repl-options :nrepl-transport-fn])
  +                   @ack-transport)
  +        (main/warn "More than one nREPL transport in use; this may not go 
well"))
  +      ;; If it's not set, this will be nil, which should select the default.
  +      (deliver ack-transport (get-in project [:repl-options 
:nrepl-transport-fn]))))
  +  (main/info "ack-server transport set to" @ack-transport))
  +
   (def ack-server
     "The server which handles ack replies."
     (delay (nrepl.server/start-server
             :bind (repl-host nil)
  +          :transport-fn @ack-transport
             :handler (nrepl.ack/handle-ack nrepl.server/unknown-op))))

   (defn nrepl-dependency? [{:keys [dependencies]}]
  @@ -235,6 +254,7 @@
       (main/info "Warning: no nREPL dependency detected.")
       (main/info "Be sure to include org.clojure/tools.nrepl in :dependencies"
                  "of your profile."))
  +  (configure-ack-transport project)
     (let [prep-blocker @eval/prep-blocker
           ack-port (:port @ack-server)]
       (-> (bound-fn []
  @@ -307,7 +327,10 @@ deactivated, but it can be overridden."
     ([project] (repl project ":start"))
     ([project subcommand & opts]
      (let [repl-profiles (project/profiles-with-matching-meta project :repl)
  -         project (project/merge-profiles project repl-profiles)]
  +         project (project/merge-profiles project repl-profiles)
  +         project (update-in project
  +                            [:repl-options :nrepl-transport-fn]
  +                            #(eval/eval-in-project project %))]
        (if (= subcommand ":connect")
          (client project (doto (connect-string project opts)
                            (->> (main/info "Connecting to nREPL at"))))


Once all of those changes are in place, the core of the (currently
simplistic) authorization plugin looks like this (tnt is
tools.nrepl.transport):

  (def auth-token
    (delay (or (read-auth-token)
               (throw (Exception. "No token; have you run \"lein token-auth 
save-token\"?")))))

  (deftype TokenGate [auth-token sub-transport]
    tnt/Transport
    (send [this msg]
      (let [msg (assoc msg :auth auth-token)]
        (tnt/send sub-transport msg)))
    (recv [this] (tnt/recv this Long/MAX_VALUE))
    (recv [this timeout]
      (let [msg (tnt/recv sub-transport timeout)
            msg-token (:auth msg)]
        (cond
          (not msg) msg
          (not msg-token)
          (throw (Exception.
                  "Incoming repl message has no authorization information"))
          (not= msg-token auth-token)
          (throw (Exception.
                  "Incoming repl message has invalid authorization 
information"))
          :else (dissoc msg :auth))))
    java.io.Closeable
    (close [this]
      (.close sub-transport)))

  (defn token-authorized-transport
    ([socket] (token-authorized-transport socket socket socket))
    ([in out & [socket]]
     (let [bencoder (tnt/bencode in out socket)]
       (TokenGate. @auth-token bencoder))))

Thanks
-- 
Rob Browning
rlb @defaultvalue.org and @debian.org
GPG as of 2011-07-10 E6A9 DA3C C9FD 1FF8 C676 D2C4 C0F0 39E9 ED1B 597A
GPG as of 2002-11-03 14DD 432F AE39 534D B592 F9A0 25C8 D377 8C7E 73A4

Other related posts: