From c0435fdff469de6046c693ad283f927428fd4910 Mon Sep 17 00:00:00 2001 From: Boaz Yaniv Date: Thu, 20 Jul 2023 23:42:09 +0900 Subject: [PATCH] Add API Keys for fzf --listen (#3374) --- CHANGELOG.md | 14 ++++++++++++++ man/man1/fzf.1 | 13 ++++++++++++- src/options.go | 1 + src/server.go | 43 +++++++++++++++++++++++++++++++++++-------- test/test_go.rb | 21 +++++++++++++++++++++ 5 files changed, 83 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edcf21dfe03..fe8c310b6c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ CHANGELOG ========= +0.42.1 +------ +- `--listen` server can be secured by setting `$FZF_API_KEY` environment + variable. + ```sh + export FZF_API_KEY="$(head -c 32 /dev/urandom | base64)" + + # Server + fzf --listen 6266 + + # Client + curl localhost:6266 -H "x-api-key: $FZF_API_KEY" -d 'change-query(yo)' + ``` + 0.42.0 ------ - Added new info style: `--info=right` diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 15c8ca6d056..34024685c74 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -772,7 +772,9 @@ Start HTTP server on the given port. It allows external processes to send actions to perform via POST method. If the port number is omitted or given as 0, fzf will choose the port automatically and export it as \fBFZF_PORT\fR environment variable to the child processes started via \fBexecute\fR and -\fBexecute-silent\fR actions. +\fBexecute-silent\fR actions. If \fBFZF_API_KEY\fR environment variable is +set, the server would require sending an API key with the same value in the +\fBx-api-key\fR HTTP header. e.g. \fB# Start HTTP server on port 6266 @@ -781,6 +783,10 @@ e.g. # Send action to the server curl -XPOST localhost:6266 -d 'reload(seq 100)+change-prompt(hundred> )' + # Start HTTP server on port 6266 and send an authenticated action + export FZF_API_KEY="$(head -c 32 /dev/urandom | base64)" + curl -XPOST localhost:6266 -H "x-api-key: $FZF_API_KEY" -d 'change-query(yo)' + # Choose port automatically and export it as $FZF_PORT to the child process fzf --listen --bind 'start:execute-silent:echo $FZF_PORT > /tmp/fzf-port' \fR @@ -800,6 +806,11 @@ this case make sure that the command is POSIX-compliant. .TP .B FZF_DEFAULT_OPTS Default options. e.g. \fBexport FZF_DEFAULT_OPTS="--extended --cycle"\fR +.TP +.B FZF_API_KEY +Can be used to require an API key when using \fB--listen\fR option. If not set, +no authentication will be required by the server. You can set this value if +you need to protect against DNS rebinding and privilege escalation attacks. .SH EXIT STATUS .BR 0 " Normal exit" diff --git a/src/options.go b/src/options.go index 87f3992954c..98770ff9b3e 100644 --- a/src/options.go +++ b/src/options.go @@ -125,6 +125,7 @@ const usage = `usage: fzf [options] FZF_DEFAULT_COMMAND Default command to use when input is tty FZF_DEFAULT_OPTS Default options (e.g. '--layout=reverse --inline-info') + FZF_API_KEY X-API-Key header for HTTP server (--listen) ` diff --git a/src/server.go b/src/server.go index 89a7938c512..c583400f87c 100644 --- a/src/server.go +++ b/src/server.go @@ -3,9 +3,11 @@ package fzf import ( "bufio" "bytes" + "crypto/subtle" "errors" "fmt" "net" + "os" "strconv" "strings" "time" @@ -15,10 +17,16 @@ const ( crlf = "\r\n" httpOk = "HTTP/1.1 200 OK" + crlf httpBadRequest = "HTTP/1.1 400 Bad Request" + crlf + httpUnauthorized = "HTTP/1.1 401 Unauthorized" + crlf httpReadTimeout = 10 * time.Second maxContentLength = 1024 * 1024 ) +type httpServer struct { + apiKey []byte + channel chan []*action +} + func startHttpServer(port int, channel chan []*action) (error, int) { if port < 0 { return nil, port @@ -41,6 +49,11 @@ func startHttpServer(port int, channel chan []*action) (error, int) { } } + server := httpServer{ + apiKey: []byte(os.Getenv("FZF_API_KEY")), + channel: channel, + } + go func() { for { conn, err := listener.Accept() @@ -51,7 +64,7 @@ func startHttpServer(port int, channel chan []*action) (error, int) { continue } } - conn.Write([]byte(handleHttpRequest(conn, channel))) + conn.Write([]byte(server.handleHttpRequest(conn))) conn.Close() } listener.Close() @@ -66,9 +79,14 @@ func startHttpServer(port int, channel chan []*action) (error, int) { // * No --listen: 2.8MB // * --listen with net/http: 5.7MB // * --listen w/o net/http: 3.3MB -func handleHttpRequest(conn net.Conn, channel chan []*action) string { +func (server *httpServer) handleHttpRequest(conn net.Conn) string { contentLength := 0 + apiKey := "" body := "" + unauthorized := func(message string) string { + message += "\n" + return httpUnauthorized + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message) + } bad := func(message string) string { message += "\n" return httpBadRequest + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message) @@ -105,18 +123,27 @@ func handleHttpRequest(conn net.Conn, channel chan []*action) string { continue } pair := strings.SplitN(text, ":", 2) - if len(pair) == 2 && strings.ToLower(pair[0]) == "content-length" { - length, err := strconv.Atoi(strings.TrimSpace(pair[1])) - if err != nil || length <= 0 || length > maxContentLength { - return bad("invalid content length") + if len(pair) == 2 { + switch strings.ToLower(pair[0]) { + case "content-length": + length, err := strconv.Atoi(strings.TrimSpace(pair[1])) + if err != nil || length <= 0 || length > maxContentLength { + return bad("invalid content length") + } + contentLength = length + case "x-api-key": + apiKey = strings.TrimSpace(pair[1]) } - contentLength = length } case 2: body += text } } + if len(server.apiKey) != 0 && subtle.ConstantTimeCompare([]byte(apiKey), server.apiKey) != 1 { + return unauthorized("invalid api key") + } + if len(body) < contentLength { return bad("incomplete request") } @@ -133,6 +160,6 @@ func handleHttpRequest(conn net.Conn, channel chan []*action) string { return bad("no action specified") } - channel <- actions + server.channel <- actions return httpOk } diff --git a/test/test_go.rb b/test/test_go.rb index 0062cf1156f..534cbba5ff1 100755 --- a/test/test_go.rb +++ b/test/test_go.rb @@ -16,6 +16,7 @@ FZF_CTRL_T_COMMAND FZF_CTRL_T_OPTS FZF_ALT_C_COMMAND FZF_ALT_C_OPTS FZF_CTRL_R_OPTS + FZF_API_KEY fish_history ].freeze DEFAULT_TIMEOUT = 10 @@ -2750,6 +2751,26 @@ def test_listen end end + def test_listen_with_api_key + post_uri = URI('http://localhost:6266') + tmux.send_keys 'seq 10 | FZF_API_KEY=123abc fzf --listen 6266', :Enter + tmux.until { |lines| assert_equal 10, lines.item_count } + # Incorrect API Key + [nil, { 'x-api-key' => '' }, { 'x-api-key' => '124abc' }].each do |headers| + res = Net::HTTP.post(post_uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers) + assert_equal '401', res.code + assert_equal 'Unauthorized', res.message + assert_equal "invalid api key\n", res.body + end + # Valid API Key + [{ 'x-api-key' => '123abc' }, { 'X-API-Key' => '123abc' }].each do |headers| + res = Net::HTTP.post(post_uri, 'change-query(yo)+reload(seq 100)+change-prompt:hundred> ', headers) + assert_equal '200', res.code + tmux.until { |lines| assert_equal 100, lines.item_count } + tmux.until { |lines| assert_equal 'hundred> yo', lines[-1] } + end + end + def test_toggle_alternative_preview_window tmux.send_keys "seq 10 | #{FZF} --bind space:toggle-preview --preview-window '<100000(hidden,up,border-none)' --preview 'echo /{}/{}/'", :Enter tmux.until { |lines| assert_equal 10, lines.item_count }