├── .gitignore ├── .travis.yml ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md ├── config.example.yaml ├── docker-compose.test.yml ├── extractors.go ├── extractors_test.go ├── integration_test.go ├── main.go ├── mutator.go ├── mutators_test.go ├── proxy.go ├── rbac.go ├── rbac_test.go └── test.sh /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | vendor 3 | .idea 4 | debug 5 | debug.test 6 | .vscode 7 | deflek 8 | .atom 9 | deflEK -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.8.x 5 | - 1.9.x 6 | - 1.10.x 7 | 8 | before_install: 9 | - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 10 | - dep ensure 11 | 12 | script: 13 | - go build 14 | - cp config.example.yaml config.yaml; go test -race -coverprofile=coverage.txt -covermode=atomic 15 | 16 | after_success: 17 | - bash <(curl -s https://codecov.io/bash) -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | ARG GO_VERSION=1.10 3 | ARG PROJECT_PATH=/go/src/github.com/dustin-decker/deflek 4 | FROM golang:${GO_VERSION}-alpine AS builder 5 | RUN apk --update add ca-certificates 6 | RUN apk add --no-cache git 7 | ADD https://github.com/golang/dep/releases/download/v0.4.1/dep-linux-amd64 /usr/bin/dep 8 | RUN chmod +x /usr/bin/dep 9 | RUN adduser -D -u 59999 container-user 10 | WORKDIR /go/src/github.com/dustin-decker/deflek 11 | COPY Gopkg.toml Gopkg.lock ./ 12 | RUN dep ensure --vendor-only 13 | COPY ./ ${PROJECT_PATH} 14 | RUN export PATH=$PATH:`go env GOHOSTOS`-`go env GOHOSTARCH` \ 15 | && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build --ldflags '-extldflags "-static"' -o deflek \ 16 | && go test $(go list ./... | grep -v /vendor/) 17 | 18 | # Production image 19 | FROM scratch 20 | EXPOSE 8080 21 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 22 | COPY --from=builder /etc/passwd /etc/passwd 23 | COPY --from=builder /go/src/github.com/dustin-decker/deflek/deflek /deflek 24 | COPY --from=builder /go/src/github.com/dustin-decker/deflek/config.example.yaml /config.example.yaml 25 | COPY --from=builder /go/src/github.com/dustin-decker/deflek/config.yaml /config.yaml 26 | USER container-user 27 | ENTRYPOINT ["/deflek"]] -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/go-stack/stack" 6 | packages = ["."] 7 | revision = "259ab82a6cad3992b4e21ff5cac294ccb06474bc" 8 | version = "v1.7.0" 9 | 10 | [[projects]] 11 | name = "github.com/google/go-cmp" 12 | packages = [ 13 | "cmp", 14 | "cmp/internal/diff", 15 | "cmp/internal/function", 16 | "cmp/internal/value" 17 | ] 18 | revision = "3af367b6b30c263d47e8895973edcca9a49cf029" 19 | version = "v0.2.0" 20 | 21 | [[projects]] 22 | name = "github.com/inconshreveable/log15" 23 | packages = ["."] 24 | revision = "0decfc6c20d9ca0ad143b0e89dcaa20f810b4fb3" 25 | version = "v2.13" 26 | 27 | [[projects]] 28 | branch = "master" 29 | name = "github.com/mailru/easyjson" 30 | packages = [ 31 | ".", 32 | "buffer", 33 | "jlexer", 34 | "jwriter" 35 | ] 36 | revision = "32fa128f234d041f196a9f3e0fea5ac9772c08e1" 37 | 38 | [[projects]] 39 | name = "github.com/mattn/go-colorable" 40 | packages = ["."] 41 | revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" 42 | version = "v0.0.9" 43 | 44 | [[projects]] 45 | name = "github.com/mattn/go-isatty" 46 | packages = ["."] 47 | revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" 48 | version = "v0.0.3" 49 | 50 | [[projects]] 51 | name = "github.com/olivere/elastic" 52 | packages = [ 53 | ".", 54 | "config", 55 | "uritemplates" 56 | ] 57 | revision = "9f4560b20fb3bd4bb855fada3e6feea59b26ce66" 58 | version = "v6.1.7" 59 | 60 | [[projects]] 61 | name = "github.com/pkg/errors" 62 | packages = ["."] 63 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 64 | version = "v0.8.0" 65 | 66 | [[projects]] 67 | branch = "master" 68 | name = "github.com/ryanuber/go-glob" 69 | packages = ["."] 70 | revision = "256dc444b735e061061cf46c809487313d5b0065" 71 | 72 | [[projects]] 73 | name = "github.com/sirupsen/logrus" 74 | packages = ["."] 75 | revision = "d682213848ed68c0a260ca37d6dd5ace8423f5ba" 76 | version = "v1.0.4" 77 | 78 | [[projects]] 79 | branch = "master" 80 | name = "golang.org/x/crypto" 81 | packages = ["ssh/terminal"] 82 | revision = "650f4a345ab4e5b245a3034b110ebc7299e68186" 83 | 84 | [[projects]] 85 | branch = "master" 86 | name = "golang.org/x/sys" 87 | packages = [ 88 | "unix", 89 | "windows" 90 | ] 91 | revision = "37707fdb30a5b38865cfb95e5aab41707daec7fd" 92 | 93 | [[projects]] 94 | branch = "v2" 95 | name = "gopkg.in/yaml.v2" 96 | packages = ["."] 97 | revision = "d670f9405373e636a5a2765eea47fac0c9bc91a4" 98 | 99 | [solve-meta] 100 | analyzer-name = "dep" 101 | analyzer-version = 1 102 | inputs-digest = "dcd1d811d15bbc5675960805a8f55e14a97f2407851f43b5d7ef3dce036e7e02" 103 | solver-name = "gps-cdcl" 104 | solver-version = 1 105 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | 24 | [[constraint]] 25 | branch = "master" 26 | name = "github.com/ryanuber/go-glob" 27 | 28 | [[constraint]] 29 | branch = "v2" 30 | name = "gopkg.in/yaml.v2" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ### GNU LESSER GENERAL PUBLIC LICENSE 2 | 3 | Version 3, 29 June 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | This version of the GNU Lesser General Public License incorporates the 12 | terms and conditions of version 3 of the GNU General Public License, 13 | supplemented by the additional permissions listed below. 14 | 15 | #### 0. Additional Definitions. 16 | 17 | As used herein, "this License" refers to version 3 of the GNU Lesser 18 | General Public License, and the "GNU GPL" refers to version 3 of the 19 | GNU General Public License. 20 | 21 | "The Library" refers to a covered work governed by this License, other 22 | than an Application or a Combined Work as defined below. 23 | 24 | An "Application" is any work that makes use of an interface provided 25 | by the Library, but which is not otherwise based on the Library. 26 | Defining a subclass of a class defined by the Library is deemed a mode 27 | of using an interface provided by the Library. 28 | 29 | A "Combined Work" is a work produced by combining or linking an 30 | Application with the Library. The particular version of the Library 31 | with which the Combined Work was made is also called the "Linked 32 | Version". 33 | 34 | The "Minimal Corresponding Source" for a Combined Work means the 35 | Corresponding Source for the Combined Work, excluding any source code 36 | for portions of the Combined Work that, considered in isolation, are 37 | based on the Application, and not on the Linked Version. 38 | 39 | The "Corresponding Application Code" for a Combined Work means the 40 | object code and/or source code for the Application, including any data 41 | and utility programs needed for reproducing the Combined Work from the 42 | Application, but excluding the System Libraries of the Combined Work. 43 | 44 | #### 1. Exception to Section 3 of the GNU GPL. 45 | 46 | You may convey a covered work under sections 3 and 4 of this License 47 | without being bound by section 3 of the GNU GPL. 48 | 49 | #### 2. Conveying Modified Versions. 50 | 51 | If you modify a copy of the Library, and, in your modifications, a 52 | facility refers to a function or data to be supplied by an Application 53 | that uses the facility (other than as an argument passed when the 54 | facility is invoked), then you may convey a copy of the modified 55 | version: 56 | 57 | - a) under this License, provided that you make a good faith effort 58 | to ensure that, in the event an Application does not supply the 59 | function or data, the facility still operates, and performs 60 | whatever part of its purpose remains meaningful, or 61 | - b) under the GNU GPL, with none of the additional permissions of 62 | this License applicable to that copy. 63 | 64 | #### 3. Object Code Incorporating Material from Library Header Files. 65 | 66 | The object code form of an Application may incorporate material from a 67 | header file that is part of the Library. You may convey such object 68 | code under terms of your choice, provided that, if the incorporated 69 | material is not limited to numerical parameters, data structure 70 | layouts and accessors, or small macros, inline functions and templates 71 | (ten or fewer lines in length), you do both of the following: 72 | 73 | - a) Give prominent notice with each copy of the object code that 74 | the Library is used in it and that the Library and its use are 75 | covered by this License. 76 | - b) Accompany the object code with a copy of the GNU GPL and this 77 | license document. 78 | 79 | #### 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, taken 82 | together, effectively do not restrict modification of the portions of 83 | the Library contained in the Combined Work and reverse engineering for 84 | debugging such modifications, if you also do each of the following: 85 | 86 | - a) Give prominent notice with each copy of the Combined Work that 87 | the Library is used in it and that the Library and its use are 88 | covered by this License. 89 | - b) Accompany the Combined Work with a copy of the GNU GPL and this 90 | license document. 91 | - c) For a Combined Work that displays copyright notices during 92 | execution, include the copyright notice for the Library among 93 | these notices, as well as a reference directing the user to the 94 | copies of the GNU GPL and this license document. 95 | - d) Do one of the following: 96 | - 0) Convey the Minimal Corresponding Source under the terms of 97 | this License, and the Corresponding Application Code in a form 98 | suitable for, and under terms that permit, the user to 99 | recombine or relink the Application with a modified version of 100 | the Linked Version to produce a modified Combined Work, in the 101 | manner specified by section 6 of the GNU GPL for conveying 102 | Corresponding Source. 103 | - 1) Use a suitable shared library mechanism for linking with 104 | the Library. A suitable mechanism is one that (a) uses at run 105 | time a copy of the Library already present on the user's 106 | computer system, and (b) will operate properly with a modified 107 | version of the Library that is interface-compatible with the 108 | Linked Version. 109 | - e) Provide Installation Information, but only if you would 110 | otherwise be required to provide such information under section 6 111 | of the GNU GPL, and only to the extent that such information is 112 | necessary to install and execute a modified version of the 113 | Combined Work produced by recombining or relinking the Application 114 | with a modified version of the Linked Version. (If you use option 115 | 4d0, the Installation Information must accompany the Minimal 116 | Corresponding Source and Corresponding Application Code. If you 117 | use option 4d1, you must provide the Installation Information in 118 | the manner specified by section 6 of the GNU GPL for conveying 119 | Corresponding Source.) 120 | 121 | #### 5. Combined Libraries. 122 | 123 | You may place library facilities that are a work based on the Library 124 | side by side in a single library together with other library 125 | facilities that are not Applications and are not covered by this 126 | License, and convey such a combined library under terms of your 127 | choice, if you do both of the following: 128 | 129 | - a) Accompany the combined library with a copy of the same work 130 | based on the Library, uncombined with any other library 131 | facilities, conveyed under the terms of this License. 132 | - b) Give prominent notice with the combined library that part of it 133 | is a work based on the Library, and explaining where to find the 134 | accompanying uncombined form of the same work. 135 | 136 | #### 6. Revised Versions of the GNU Lesser General Public License. 137 | 138 | The Free Software Foundation may publish revised and/or new versions 139 | of the GNU Lesser General Public License from time to time. Such new 140 | versions will be similar in spirit to the present version, but may 141 | differ in detail to address new problems or concerns. 142 | 143 | Each version is given a distinguishing version number. If the Library 144 | as you received it specifies that a certain numbered version of the 145 | GNU Lesser General Public License "or any later version" applies to 146 | it, you have the option of following the terms and conditions either 147 | of that published version or of any later version published by the 148 | Free Software Foundation. If the Library as you received it does not 149 | specify a version number of the GNU Lesser General Public License, you 150 | may choose any version of the GNU Lesser General Public License ever 151 | published by the Free Software Foundation. 152 | 153 | If the Library as you received it specifies that a proxy can decide 154 | whether future versions of the GNU Lesser General Public License shall 155 | apply, that proxy's public statement of acceptance of any version is 156 | permanent authorization for you to choose that version for the 157 | Library. 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # deflEK 2 | 3 | Reverse proxy that adds index-level RBAC to Elasticsearch. 4 | 5 | [![Travis CI Build Status](https://travis-ci.com/dustin-decker/deflek.svg?branch=master)](https://travis-ci.com/dustin-decker/deflek) 6 | 7 | ## Disclaimer 8 | 9 | Deflek man-in-the-middles requests to elasticsearch in order to apply a best effort to filter access 10 | and mutate requests to be compatible, and to provide an audit log. It is not perfect, and probably never will be. Elasticsearch needs security to be baked in to do it properly. There are solutions that come closer to this, 11 | like [ReadOnlyREST](https://github.com/sscarduzio/elasticsearch-readonlyrest-plugin), [Search Guard](https://github.com/floragunncom/search-guard) or Elastic's own [X-pack security](https://www.elastic.co/guide/en/x-pack/current/xpack-security.html), but all of those are also bolt-on security, in 12 | the form of an Elasticsearch plugin. So use it at your own risk! Help make it better! Make a PR to add proper RBAC 13 | to the core of Elasticsearch! 14 | 15 | ## Authentication 16 | 17 | It currently requires fronting with a SSO authentication proxy (such as [saml-proxy](https://github.com/dustin-decker/saml-proxy)) to pass Username and Group headers for RBAC lookup. deflEK assumes these headers are trusted input. If that is not true for your use case, you MUST add your own authentication middleware, or else it will not work. 18 | 19 | An example setup looks like this: 20 | 21 | `USER -> saml-proxy -> Kibana -> deflek -> Elasticsearch` 22 | 23 | To have Kibana pass user and group headers from `saml-proxy` to `deflek`, use Kibana's `elasticsearch.requestHeadersWhitelist` configuration option, documented here: https://www.elastic.co/guide/en/kibana/6.2/settings.html 24 | The headers specified in `config.example.yaml` would be specified like this: 25 | 26 | ``` 27 | elasticsearch.requestHeadersWhitelist: ["X-Remote-Groups", "X-Remote-User"] 28 | ``` 29 | 30 | ## Features 31 | 32 | - RBAC on indices and APIs 33 | - Request traces - elasped time, query, errors, user, groups, indices, response code 34 | - JSON logging, ready for indexing 35 | 36 | ## Coverage 37 | 38 | deflek can enforce RBAC on HTTP methods for every HTTP API elasticsearch offers 39 | 40 | aditionally, deflek has index awareness for the following APIs: 41 | 42 | - _mget 43 | - _msearch 44 | - _all 45 | - _search 46 | - direct index access (/< index >/1) 47 | 48 | deflek can also mutate wildcard requests on the fly, to support software like Kibana. 49 | 50 | ## Configuration 51 | 52 | `config.example.yaml` is included as a sample configuration file. This is also the config that should be used with integration tests. It includes the indices and API whitelisting necessary to support Kibana. 53 | 54 | You will need to edit the headers to match what your authentication layer passes to deflek. You will also need to modify groups access to match what will be included via those headers. 55 | 56 | ## Running it 57 | 58 | Build docker image: 59 | 60 | ``` bash 61 | docker build -t deflek . 62 | ``` 63 | 64 | Deploy test stack to local Swarm: 65 | 66 | ``` bash 67 | docker stack deploy -c docker-compose.test.yml deflek 68 | ``` 69 | 70 | ## Testing it 71 | 72 | Ensure you have the dependencies: 73 | 74 | ``` bash 75 | dep ensure 76 | ``` 77 | 78 | Use the example config: 79 | 80 | ``` bash 81 | cp config.example.yaml config.yaml 82 | ``` 83 | 84 | Run a test elasticsearch cluster, if needed: 85 | 86 | ``` bash 87 | docker run -p 127.0.0.1:9200:9200 --rm -it -e "discovery.type=single-node" -v esdata1:/usr/share/elasticsearch/data docker.elastic.co/elasticsearch/elasticsearch-oss:6.2.1 88 | ``` 89 | 90 | Build and run deflek: 91 | 92 | ``` bash 93 | go build; ./deflEK 94 | ``` 95 | 96 | Run deflek integration and unit tests: 97 | 98 | ``` bash 99 | go test 100 | ``` -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | listen_interface: 127.0.0.1 2 | listen_port: 8080 3 | target: http://127.0.0.1:9200 4 | json_logging: false 5 | anonymous_group: group1 6 | group_header_name: X-Remote-Groups 7 | group_header_type: AD 8 | user_header_name: X-Remote-User 9 | 10 | rbac: 11 | groups: 12 | group1: 13 | whitelisted_indices: 14 | - name: secret_stuff 15 | rest_verbs: 16 | - GET 17 | - POST 18 | 19 | ### req'd for kibana 20 | - name: .kibana 21 | rest_verbs: 22 | - GET 23 | - POST 24 | 25 | # YAML supports pointers 26 | whitelisted_apis: *kibana 27 | 28 | can_manage: false 29 | 30 | group2: 31 | can_manage: true 32 | whitelisted_indices: 33 | - name: test_deflek 34 | rest_verbs: 35 | - GET 36 | - POST 37 | - name: test_deflek2 38 | rest_verbs: 39 | - GET 40 | - name: globby-* 41 | rest_verbs: 42 | - GET 43 | 44 | ### req'd for kibana 45 | - name: .kibana 46 | rest_verbs: 47 | - GET 48 | - POST 49 | 50 | # you can reuse this declaration with a YAML pointer 51 | whitelisted_apis: &kibana 52 | - name: _msearch 53 | rest_verbs: ["POST"] 54 | - name: _all 55 | rest_verbs: ["GET", "POST"] 56 | - name: _search 57 | rest_verbs: ["GET", "POST"] 58 | - name: _mget 59 | rest_verbs: ["POST"] 60 | - name: _mappings 61 | rest_verbs: ["GET"] 62 | - name: _mapping 63 | rest_verbs: ["GET"] 64 | - name: _local 65 | rest_verbs: ["GET"] 66 | - name: _aliases 67 | rest_verbs: ["GET"] 68 | - name: _field_stats 69 | rest_verbs: ["POST"] 70 | - name: _nodes 71 | rest_verbs: ["GET"] 72 | # for settings update 73 | - name: _template 74 | rest_verbs: ["PUT", "GET"] 75 | - name: _update 76 | rest_verbs: ["POST"] 77 | - name: _create 78 | rest_verbs: ["POST"] 79 | - name: _field_caps 80 | rest_verbs: ["POST"] -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | networks: 4 | test-deflek: 5 | 6 | configs: 7 | deflek: 8 | file: ./config.example.yaml 9 | 10 | services: 11 | app: 12 | image: deflek 13 | ports: 14 | - "127.0.0.1:8080:8080" 15 | networks: 16 | - test-deflek 17 | configs: 18 | - source: deflek 19 | target: /config.yaml 20 | deploy: 21 | replicas: 1 22 | restart_policy: 23 | condition: any 24 | delay: 30s 25 | 26 | elasticsearch: 27 | image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.0.0 28 | volumes: 29 | - ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 30 | ports: 31 | - "127.0.0.1:9200:9200" 32 | - "127.0.0.1:9300:9300" 33 | environment: 34 | ES_JAVA_OPTS: "-Xmx256m -Xms256m" 35 | networks: 36 | - test-deflek -------------------------------------------------------------------------------- /extractors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | // extract indices from the incoming request 11 | func extractIndices(ctx *requestContext) ([]string, error) { 12 | var indices []string 13 | 14 | // extract the indices specified in the body, which can be 15 | // specified in many different ways depending on the API :[ 16 | ib, err := extractBodyIndices(ctx.firstPathComponent, ctx.body) 17 | if err != nil { 18 | return indices, err 19 | } 20 | if len(ib) > 0 { 21 | indices = append(indices, ib...) 22 | } 23 | ctx.indices = indices 24 | ctx.trace.Access = indices 25 | 26 | // extract indices from te URI path 27 | iu, err := extractURIindices(ctx.r) 28 | if err != nil { 29 | return indices, err 30 | } 31 | if len(iu) > 0 { 32 | indices = append(indices, iu...) 33 | } 34 | ctx.indices = indices 35 | ctx.trace.Access = indices 36 | 37 | return indices, nil 38 | } 39 | 40 | // multi document get can hit many different indices 41 | // in the request body. get 'em all here 42 | type mgetBody struct { 43 | Docs []struct { 44 | // XXX: Fill in as needed ... 45 | Index string `json:"_index"` 46 | // XXX: ... 47 | } `json:"docs"` 48 | } 49 | 50 | // older versions of kibana use this format 51 | type msearchBodyString struct { 52 | // XXX: Fill in as needed ... 53 | Index string `json:"index"` 54 | // XXX: ... 55 | } 56 | 57 | // newer version of kibana use this format 58 | type msearchBodyArray struct { 59 | // XXX: Fill in as needed ... 60 | Index []string `json:"index"` 61 | // XXX: ... 62 | } 63 | 64 | type bulk struct { 65 | Index string `json:"_index"` 66 | } 67 | 68 | // extract indices from the incoming request body 69 | func extractBodyIndices(api string, body []byte) ([]string, error) { 70 | var indices []string 71 | 72 | // special case here. 73 | // bulk API problably does this too, but I haven't gotten to that yet 74 | // NDJSON. Savages. 75 | JSONs := bytes.Split(body, []byte("\n")) 76 | for _, JSON := range JSONs { 77 | 78 | // attempt older string syntax 79 | var msB msearchBodyString 80 | json.Unmarshal(JSON, &msB) 81 | 82 | if msB.Index != "" { 83 | indices = append(indices, strings.Split(msB.Index, ",")...) 84 | } 85 | 86 | // attempt newer array syntax 87 | var msBA msearchBodyArray 88 | json.Unmarshal(JSON, &msBA) 89 | 90 | if len(msBA.Index) > 0 { 91 | for _, index := range msBA.Index { 92 | indices = append(indices, strings.Split(index, ",")...) 93 | } 94 | } 95 | 96 | // bulk API 97 | m := map[string]bulk{} 98 | json.Unmarshal(JSON, &m) 99 | for _, v := range m { 100 | if len(v.Index) > 0 { 101 | indices = append(indices, strings.Split(v.Index, ",")...) 102 | } 103 | } 104 | 105 | } 106 | 107 | // extract indices from the way of mget 108 | var mgB mgetBody 109 | json.Unmarshal(body, &mgB) 110 | for _, doc := range mgB.Docs { 111 | if doc.Index != "" { 112 | indices = append(indices, strings.Split(doc.Index, ",")...) 113 | } 114 | } 115 | 116 | return indices, nil 117 | } 118 | 119 | // extract indices that are specified in the URI 120 | func extractURIindices(r *http.Request) ([]string, error) { 121 | index := getFirstPathComponent(r) 122 | var indices []string 123 | if len(index) > 1 && !strings.HasPrefix(index, "_") { 124 | indices = strings.Split(index, ",") 125 | } 126 | 127 | return indices, nil 128 | } 129 | 130 | // extract API that are specified in the URI 131 | func extractAPI(r *http.Request) string { 132 | api := getFirstPathComponent(r) 133 | if len(api) > 1 { 134 | for _, elem := range strings.Split(r.URL.Path, "/") { 135 | if strings.HasPrefix(elem, "_") { 136 | return elem 137 | } 138 | } 139 | } 140 | 141 | return "" 142 | } 143 | -------------------------------------------------------------------------------- /extractors_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | ) 8 | 9 | func TestExtractURIindices(t *testing.T) { 10 | var req http.Request 11 | req.URL, _ = url.Parse("http://localhost:9200/test1,test2/_search?q=tag:wow") 12 | 13 | indices, err := extractURIindices(&req) 14 | if err != nil { 15 | t.Error("couldn't extract URI indices, got: ", err) 16 | } 17 | 18 | if !stringInSlice("test1", indices) { 19 | t.Error("expected 'test' in indices, got: ", indices) 20 | } 21 | 22 | if !stringInSlice("test2", indices) { 23 | t.Error("expected 'test' in indices, got: ", indices) 24 | } 25 | } 26 | 27 | func TestExtractBodyMsearch(t *testing.T) { 28 | // based on the docs example. modified to include two indices 29 | // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-multi-search.html 30 | // added array syntax 31 | body := ` 32 | {"index" : "test"} 33 | {"query" : {"match_all" : {}}, "from" : 0, "size" : 10} 34 | {"index" : ["test2","test3"], "search_type" : "dfs_query_then_fetch"} 35 | {"query" : {"match_all" : {}}} 36 | {} 37 | {"query" : {"match_all" : {}}} 38 | 39 | {"query" : {"match_all" : {}}} 40 | {"search_type" : "dfs_query_then_fetch"} 41 | {"query" : {"match_all" : {}}} 42 | ` 43 | 44 | indices, err := extractBodyIndices("_msearch", []byte(body)) 45 | if err != nil { 46 | t.Error("failed to extract body: ", err) 47 | } 48 | 49 | if !stringInSlice("test", indices) { 50 | t.Error("expected 'test' in indices, got: ", indices) 51 | } 52 | 53 | if !stringInSlice("test2", indices) { 54 | t.Error("expected 'test' in indices, got: ", indices) 55 | } 56 | 57 | if !stringInSlice("test3", indices) { 58 | t.Error("expected 'test' in indices, got: ", indices) 59 | } 60 | } 61 | 62 | func TestExtractBodyMget(t *testing.T) { 63 | // based on the docs example. modified to include two indices 64 | // https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html 65 | body := ` 66 | { 67 | "docs" : [ 68 | { 69 | "_index" : "test", 70 | "_id" : "1" 71 | }, 72 | { 73 | "_index" : "test2", 74 | "_id" : "2" 75 | } 76 | ] 77 | } 78 | ` 79 | 80 | indices, err := extractBodyIndices("_mget", []byte(body)) 81 | if err != nil { 82 | t.Error("failed to extract body: ", err) 83 | } 84 | 85 | if !stringInSlice("test", indices) { 86 | t.Error("expected 'test' in indices, got: ", indices) 87 | } 88 | 89 | if !stringInSlice("test2", indices) { 90 | t.Error("expected 'test2' in indices, got: ", indices) 91 | } 92 | } 93 | 94 | func TestExtractBodyBulk(t *testing.T) { 95 | // based on the docs example. modified to include two indices 96 | // https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html 97 | body := ` 98 | { "index" : { "_index" : "test1", "_type" : "_doc", "_id" : "1" } } 99 | { "field1" : "value1" } 100 | { "delete" : { "_index" : "test2", "_type" : "_doc", "_id" : "2" } } 101 | { "create" : { "_index" : "test3", "_type" : "_doc", "_id" : "3" } } 102 | { "field1" : "value3" } 103 | { "doc" : {"field2" : "value2"} } 104 | ` 105 | 106 | indices, err := extractBodyIndices("_bulk", []byte(body)) 107 | if err != nil { 108 | t.Error("failed to extract body: ", err) 109 | } 110 | 111 | if !stringInSlice("test1", indices) { 112 | t.Error("expected 'test1' in indices, got: ", indices) 113 | } 114 | 115 | if !stringInSlice("test2", indices) { 116 | t.Error("expected 'test2' in indices, got: ", indices) 117 | } 118 | 119 | if !stringInSlice("test3", indices) { 120 | t.Error("expected 'test3' in indices, got: ", indices) 121 | } 122 | } 123 | 124 | func TestExtractAPI(t *testing.T) { 125 | ctx, err := getTestContext("/_nodes/local", "", "GET") 126 | if err != nil { 127 | t.Error("could not get context: ", err) 128 | } 129 | api := extractAPI(ctx.r) 130 | if api != "_nodes" { 131 | t.Errorf("got %s, expected %s", api, "_nodes") 132 | } 133 | 134 | ctx, err = getTestContext("/some_index/local", "", "GET") 135 | if err != nil { 136 | t.Error("could not get context: ", err) 137 | } 138 | api = extractAPI(ctx.r) 139 | if api != "" { 140 | t.Errorf("got %s, expected %s", api, "") 141 | } 142 | 143 | ctx, err = getTestContext("/some_index/_search", "", "GET") 144 | if err != nil { 145 | t.Error("could not get context: ", err) 146 | } 147 | api = extractAPI(ctx.r) 148 | if api != "_search" { 149 | t.Errorf("got %s, expected %s", api, "_search") 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | //+build integration 2 | 3 | // requires integration tag to test like: go test -tags integration 4 | package main 5 | 6 | // these tests require the current build of deflek running with 7 | // the included `config.example.yaml` file and pointed to an ES 8 | // instance 9 | 10 | import ( 11 | "bytes" 12 | "context" 13 | "io/ioutil" 14 | "net/http" 15 | "testing" 16 | "time" 17 | 18 | log "github.com/sirupsen/logrus" 19 | 20 | "github.com/olivere/elastic" 21 | ) 22 | 23 | type authTransport struct { 24 | Transport http.RoundTripper 25 | } 26 | 27 | // custom RoundTrip injects authentication headers into the test client 28 | func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { 29 | // NOTE: this client is configured for the `config.example.yaml` included 30 | // 31 | req.Header.Add("X-Remote-User", "dustind") 32 | req.Header.Add("X-Remote-Groups", "OU=thing,CN=group2,DC=something") 33 | tr := &http.Transport{} 34 | res, err := tr.RoundTrip(req) 35 | return res, err 36 | } 37 | 38 | func createEsClient() *elastic.Client { 39 | ctx := context.Background() 40 | url := "http://127.0.0.1:8080" 41 | sniff := true 42 | 43 | httpClient := &http.Client{ 44 | Transport: &authTransport{}, 45 | Timeout: 10 * time.Second, 46 | } 47 | 48 | c, err := elastic.NewClient(elastic.SetURL(url), 49 | elastic.SetSniff(sniff), 50 | elastic.SetHttpClient(httpClient)) 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | 55 | testIndices := []string{ 56 | "test_deflek", 57 | "test_deflek2", 58 | "secret_stuff", 59 | "globby-test"} 60 | 61 | indexCreateBody := ` 62 | { 63 | "settings" : { 64 | "index" : { 65 | "number_of_shards" : 1, 66 | "number_of_replicas" : 0 67 | } 68 | } 69 | }` 70 | for _, index := range testIndices { 71 | exists, err := c.IndexExists(index).Do(ctx) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | if !exists { 76 | c.CreateIndex(index).Body(indexCreateBody).Do(ctx) 77 | c.Index().Index(index).Id("1").OpType("index").Do(ctx) 78 | } 79 | } 80 | return c 81 | } 82 | 83 | func createHTTPClient() *http.Client { 84 | httpClient := &http.Client{ 85 | Transport: &authTransport{}, 86 | Timeout: 5 * time.Second, 87 | } 88 | return httpClient 89 | } 90 | 91 | func testAllowed(t *testing.T, res *http.Response) { 92 | body, err := ioutil.ReadAll(res.Body) 93 | if err != nil { 94 | t.Error("couldn't read the body. got: ", err) 95 | } 96 | if res.StatusCode != 200 { 97 | t.Errorf("request should have been allowed. got: \n status code: %v \nbody: %s", res.StatusCode, string(body)) 98 | } 99 | } 100 | 101 | func testBlocked(t *testing.T, res *http.Response) { 102 | body, err := ioutil.ReadAll(res.Body) 103 | if err != nil { 104 | t.Error("couldn't read the body. got: ", err) 105 | } 106 | if res.StatusCode != 403 { 107 | t.Errorf("request should have been blocked. got: \n status code: %v \nbody: %s", res.StatusCode, string(body)) 108 | } 109 | } 110 | 111 | const base = "http://127.0.0.1:8080" 112 | 113 | func TestAll(t *testing.T) { 114 | createEsClient() 115 | httpC := createHTTPClient() 116 | 117 | res, err := httpC.Get(base + "/_all/_search?q=tag:wow") 118 | if err != nil { 119 | log.Fatal(err) 120 | } 121 | 122 | testAllowed(t, res) 123 | } 124 | 125 | func TestSearch(t *testing.T) { 126 | createEsClient() 127 | httpC := createHTTPClient() 128 | 129 | res, err := httpC.Get(base + "/_search?q=tag:wow") 130 | if err != nil { 131 | log.Fatal(err) 132 | } 133 | 134 | testAllowed(t, res) 135 | } 136 | 137 | func TestMsearchBlocked(t *testing.T) { 138 | createEsClient() 139 | httpC := createHTTPClient() 140 | 141 | body := ` 142 | {"index" : "test_deflek"} 143 | {"query" : {"match_all" : {}}, "from" : 0, "size" : 10} 144 | {"index" : "secret_stuff", "search_type" : "dfs_query_then_fetch"} 145 | {"query" : {"match_all" : {}}} 146 | {} 147 | {"query" : {"match_all" : {}}} 148 | 149 | {"query" : {"match_all" : {}}} 150 | {"search_type" : "dfs_query_then_fetch"} 151 | {"query" : {"match_all" : {}}} 152 | ` 153 | 154 | res, err := httpC.Post(base+"/_msearch", "application/json", bytes.NewBuffer([]byte(body))) 155 | if err != nil { 156 | log.Fatal(err) 157 | } 158 | 159 | testBlocked(t, res) 160 | } 161 | 162 | func TestMsearchAllowed(t *testing.T) { 163 | createEsClient() 164 | httpC := createHTTPClient() 165 | 166 | body := ` 167 | {"index" : "test_deflek"} 168 | {"query" : {"match_all" : {}}, "from" : 0, "size" : 10} 169 | {"index" : "test_deflek", "search_type" : "dfs_query_then_fetch"} 170 | {"query" : {"match_all" : {}}} 171 | {} 172 | {"query" : {"match_all" : {}}} 173 | 174 | {"query" : {"match_all" : {}}} 175 | {"search_type" : "dfs_query_then_fetch"} 176 | {"query" : {"match_all" : {}}} 177 | ` 178 | 179 | res, err := httpC.Post(base+"/_msearch", "application/json", bytes.NewBuffer([]byte(body))) 180 | if err != nil { 181 | log.Fatal(err) 182 | } 183 | 184 | testAllowed(t, res) 185 | } 186 | 187 | func TestMgetBlocked(t *testing.T) { 188 | createEsClient() 189 | httpC := createHTTPClient() 190 | 191 | body := ` 192 | { 193 | "docs" : [ 194 | { 195 | "_index" : "test_deflek", 196 | "_id" : "1" 197 | }, 198 | { 199 | "_index" : "secret_stuff", 200 | "_id" : "1" 201 | } 202 | ] 203 | } 204 | ` 205 | 206 | res, err := httpC.Post(base+"/_mget", "application/json", bytes.NewBuffer([]byte(body))) 207 | if err != nil { 208 | log.Fatal(err) 209 | } 210 | 211 | testBlocked(t, res) 212 | } 213 | 214 | func TestMgetAllowed(t *testing.T) { 215 | createEsClient() 216 | httpC := createHTTPClient() 217 | 218 | body := ` 219 | { 220 | "docs" : [ 221 | { 222 | "_index" : "test_deflek", 223 | "_id" : "1" 224 | }, 225 | { 226 | "_index" : "test_deflek", 227 | "_id" : "2" 228 | } 229 | ] 230 | } 231 | ` 232 | 233 | res, err := httpC.Post(base+"/_mget", "application/json", 234 | bytes.NewBuffer([]byte(body))) 235 | if err != nil { 236 | log.Fatal(err) 237 | } 238 | 239 | testAllowed(t, res) 240 | } 241 | 242 | func TestNamedIndexBlock(t *testing.T) { 243 | createEsClient() 244 | httpC := createHTTPClient() 245 | 246 | res, err := httpC.Get(base + "/secret_stuff/_search?q=tag:wow") 247 | if err != nil { 248 | log.Fatal(err) 249 | } 250 | 251 | testBlocked(t, res) 252 | } 253 | 254 | func TestNamedIndexAllow(t *testing.T) { 255 | createEsClient() 256 | httpC := createHTTPClient() 257 | 258 | res, err := httpC.Get(base + "/test_deflek/_search?q=tag:wow") 259 | if err != nil { 260 | log.Fatal(err) 261 | } 262 | 263 | testAllowed(t, res) 264 | } 265 | 266 | func TestRESTverbBlock(t *testing.T) { 267 | createEsClient() 268 | httpC := createHTTPClient() 269 | 270 | // test on index literal 271 | res, err := httpC.Post(base+"/test_deflek2/_search?q=tag:wow", 272 | "application/json", bytes.NewBuffer([]byte("{}"))) 273 | if err != nil { 274 | log.Fatal(err) 275 | } 276 | testBlocked(t, res) 277 | 278 | // test on glob patterns 279 | res, err = httpC.Post(base+"/globby-te*/_search?q=tag:wow", 280 | "application/json", 281 | bytes.NewBuffer([]byte("{}"))) 282 | if err != nil { 283 | log.Fatal(err) 284 | } 285 | testBlocked(t, res) 286 | } 287 | 288 | func TestRESTverbAllow(t *testing.T) { 289 | createEsClient() 290 | httpC := createHTTPClient() 291 | 292 | // test on index literal 293 | res, err := httpC.Post(base+"/test_deflek/_search?q=tag:wow", 294 | "application/json", bytes.NewBuffer([]byte("{}"))) 295 | if err != nil { 296 | log.Fatal(err) 297 | } 298 | testAllowed(t, res) 299 | 300 | // test on glob patterns 301 | res, err = httpC.Get(base + "/globby-t*/_search?q=tag:wow") 302 | if err != nil { 303 | log.Fatal(err) 304 | } 305 | testAllowed(t, res) 306 | } 307 | 308 | func TestGlobURI(t *testing.T) { 309 | createEsClient() 310 | httpC := createHTTPClient() 311 | 312 | res, err := httpC.Get(base + "/globby-te*/_search?q=tag:wow") 313 | if err != nil { 314 | log.Fatal(err) 315 | } 316 | 317 | testAllowed(t, res) 318 | } 319 | 320 | func TestWildcardIndexMutator(t *testing.T) { 321 | createEsClient() 322 | httpC := createHTTPClient() 323 | 324 | body := ` 325 | {"index":"*","ignore":[404],"timeout":"90s","requestTimeout":90000,"ignoreUnavailable":true} 326 | {"size":0,"query":{"bool":{"must":[{"range":{"@timestamp":{"gte":1519223869113,"lte":1519225669114,"format":"epoch_millis"}}},{"bool":{"must":[{"match_all":{}}],"must_not":[]}}]}},"aggs":{"61ca57f1-469d-11e7-af02-69e470af7417":{"filter":{"match_all":{}},"aggs":{"timeseries":{"date_histogram":{"field":"@timestamp","interval":"30s","min_doc_count":0,"time_zone":"America/Chicago","extended_bounds":{"min":1519223869113,"max":1519225669114}},"aggs":{"61ca57f2-469d-11e7-af02-69e470af7417":{"bucket_script":{"buckets_path":{"count":"_count"},"script":{"inline":"count * 1","lang":"expression"},"gap_policy":"skip"}}}}}}}} 327 | ` 328 | 329 | res, err := httpC.Post(base+"/_msearch", "application/json", bytes.NewBuffer([]byte(body))) 330 | if err != nil { 331 | log.Fatal(err) 332 | } 333 | 334 | testAllowed(t, res) 335 | } 336 | 337 | func TestWildcardURImutator(t *testing.T) { 338 | createEsClient() 339 | httpC := createHTTPClient() 340 | 341 | res, err := httpC.Get(base + "/*/_search?q=tag:wow") 342 | if err != nil { 343 | log.Fatal(err) 344 | } 345 | 346 | testAllowed(t, res) 347 | } 348 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // Config for reverse proxy settings and RBAC users and groups 9 | // Unmarshalled from config on disk 10 | type Config struct { 11 | ListenInterface string `yaml:"listen_interface"` 12 | ListenPort int `yaml:"listen_port"` 13 | Target string 14 | JSONlogging bool `yaml:"json_logging"` 15 | AnonymousGroup string `yaml:"anonymous_group"` 16 | GroupHeaderName string `yaml:"group_header_name"` 17 | GroupHeaderType string `yaml:"group_header_type"` 18 | UserHeaderName string `yaml:"user_header_name"` 19 | RBAC struct { 20 | Groups map[string]Permissions 21 | } 22 | } 23 | 24 | func main() { 25 | var C Config 26 | C.getConf("config.yaml") 27 | 28 | proxy := NewProx(&C) 29 | 30 | http.HandleFunc("/", proxy.handleRequest) 31 | http.ListenAndServe(fmt.Sprintf("%s:%d", C.ListenInterface, C.ListenPort), nil) 32 | } 33 | -------------------------------------------------------------------------------- /mutator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/url" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | // mutate things that aren't allowed in the path to things that are. 12 | // kibana requires use of this function 13 | // 14 | // `_all` gets replaced with whitelisted indices 15 | // 16 | // `_search` API gets prefixed with whitelisted indices 17 | // 18 | // `*` index pattern gets replaced with whitelisted indices 19 | // 20 | func mutatePath(ctx *requestContext) { 21 | var indices []string 22 | for _, whitelistedIndex := range ctx.whitelistedIndices { 23 | if !strings.HasPrefix(whitelistedIndex.Name, ".") { 24 | indices = append(indices, whitelistedIndex.Name) 25 | } 26 | } 27 | indicesAsURI := strings.Join(indices, ",") 28 | path := ctx.r.URL.Path 29 | path = strings.TrimPrefix(path, "/_all") 30 | path = strings.TrimPrefix(path, "/*") 31 | urlStr := "/" + indicesAsURI + path 32 | reqURL, _ := url.Parse(urlStr) 33 | ctx.r.URL = reqURL 34 | } 35 | 36 | // mutate wildcard index patterns that are specified in the body to be whitelisted indices 37 | // kibana requires use of this function 38 | // 39 | func mutateWildcardIndexInBody(ctx *requestContext) error { 40 | // this is gross 41 | body, err := getBody(ctx.r) 42 | if err != nil { 43 | return err 44 | } 45 | re := regexp.MustCompile(`\"\*\"`) 46 | cleanIndex := `"` + ctx.whitelistedIndicesNames + `"` 47 | cleanBody := re.ReplaceAllString(string(body), cleanIndex) 48 | ctx.r.Body = ioutil.NopCloser(bytes.NewReader([]byte(cleanBody))) 49 | ctx.r.ContentLength = int64(len([]byte(cleanBody))) 50 | ctx.trace.Body = cleanBody 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /mutators_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestMutatePath(t *testing.T) { 10 | ctx, err := getTestContext("/_all/_search", "", "GET") 11 | if err != nil { 12 | t.Error("could not get context: ", err) 13 | } 14 | 15 | mutatePath(ctx) 16 | 17 | expected := "/test_deflek,test_deflek2,globby-*/_search" 18 | 19 | if ctx.r.URL.Path != expected { 20 | t.Errorf("got %v, expected %v", ctx.r.URL.Path, expected) 21 | } 22 | } 23 | 24 | func TestMutateWildcardIndexInBody(t *testing.T) { 25 | body := `{"index":"*","ignore":[404],"timeout":"90s","requestTimeout":90000,"ignoreUnavailable":true} 26 | {"size":0,"query":{"bool":{"must":[{"range":{"@timestamp":{"gte":1519223869113,"lte":1519225669114,"format":"epoch_millis"}}},{"bool":{"must":[{"match_all":{}}],"must_not":[]}}]}},"aggs":{"61ca57f1-469d-11e7-af02-69e470af7417":{"filter":{"match_all":{}},"aggs":{"timeseries":{"date_histogram":{"field":"@timestamp","interval":"30s","min_doc_count":0,"time_zone":"America/Chicago","extended_bounds":{"min":1519223869113,"max":1519225669114}},"aggs":{"61ca57f2-469d-11e7-af02-69e470af7417":{"bucket_script":{"buckets_path":{"count":"_count"},"script":{"inline":"count * 1","lang":"expression"},"gap_policy":"skip"}}}}}}}} 27 | ` 28 | 29 | ctx, err := getTestContext("/_msearch", body, "GET") 30 | if err != nil { 31 | t.Error("could not get context: ", err) 32 | } 33 | 34 | mutateWildcardIndexInBody(ctx) 35 | 36 | mutatedBody, _ := getBody(ctx.r) 37 | 38 | expectedBody := `{"index":"test_deflek,test_deflek2,globby-*,.kibana","ignore":[404],"timeout":"90s","requestTimeout":90000,"ignoreUnavailable":true} 39 | {"size":0,"query":{"bool":{"must":[{"range":{"@timestamp":{"gte":1519223869113,"lte":1519225669114,"format":"epoch_millis"}}},{"bool":{"must":[{"match_all":{}}],"must_not":[]}}]}},"aggs":{"61ca57f1-469d-11e7-af02-69e470af7417":{"filter":{"match_all":{}},"aggs":{"timeseries":{"date_histogram":{"field":"@timestamp","interval":"30s","min_doc_count":0,"time_zone":"America/Chicago","extended_bounds":{"min":1519223869113,"max":1519225669114}},"aggs":{"61ca57f2-469d-11e7-af02-69e470af7417":{"bucket_script":{"buckets_path":{"count":"_count"},"script":{"inline":"count * 1","lang":"expression"},"gap_policy":"skip"}}}}}}}} 40 | ` 41 | 42 | if diff := cmp.Diff(expectedBody, string(mutatedBody)); diff != "" { 43 | t.Errorf("unexpected difference: (-got +want)\n%s", diff) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httputil" 9 | "net/url" 10 | "os" 11 | "path" 12 | "time" 13 | 14 | log "github.com/inconshreveable/log15" 15 | yaml "gopkg.in/yaml.v2" 16 | ) 17 | 18 | func (C *Config) getConf(configPath string) *Config { 19 | 20 | pwd, _ := os.Getwd() 21 | yamlFile, err := ioutil.ReadFile(path.Join(pwd, configPath)) 22 | if err != nil { 23 | log.Error(err.Error()) 24 | os.Exit(1) 25 | } 26 | err = yaml.Unmarshal(yamlFile, C) 27 | if err != nil { 28 | log.Error(err.Error()) 29 | os.Exit(1) 30 | } 31 | 32 | return C 33 | } 34 | 35 | // Prox defines our reverse proxy 36 | type Prox struct { 37 | config *Config 38 | target *url.URL 39 | proxy *httputil.ReverseProxy 40 | log log.Logger 41 | } 42 | 43 | // Trace - Request error handling wrapper on the handler 44 | type Trace struct { 45 | Path string 46 | Method string 47 | Error string 48 | Message string 49 | Code int 50 | Elapsed int 51 | User string 52 | Groups []string 53 | Body string 54 | Access []string 55 | } 56 | 57 | // NewProx returns new reverse proxy instance 58 | func NewProx(C *Config) *Prox { 59 | url, _ := url.Parse(C.Target) 60 | 61 | logger := log.New() 62 | if C.JSONlogging { 63 | logger.SetHandler(log.MultiHandler(log.StreamHandler(os.Stderr, 64 | log.JsonFormat()))) 65 | } 66 | 67 | return &Prox{ 68 | config: C, 69 | target: url, 70 | proxy: httputil.NewSingleHostReverseProxy(url), 71 | log: logger, 72 | } 73 | } 74 | 75 | type traceTransport struct { 76 | Response *http.Response 77 | } 78 | 79 | func (p *Prox) handleRequest(w http.ResponseWriter, r *http.Request) { 80 | start := time.Now() 81 | trace := Trace{} 82 | 83 | ctx, err := getRequestContext(r, p.config, &trace) 84 | if err != nil { 85 | trace.Error = err.Error() 86 | w.WriteHeader(http.StatusBadRequest) 87 | } 88 | 89 | trans := traceTransport{} 90 | p.proxy.Transport = &trans 91 | 92 | ok, err := p.checkRBAC(ctx) 93 | if !ok { 94 | w.WriteHeader(http.StatusUnauthorized) 95 | } else if err != nil { 96 | trace.Error = err.Error() 97 | w.WriteHeader(http.StatusUnauthorized) 98 | } else { 99 | p.proxy.ServeHTTP(w, r) 100 | } 101 | 102 | trace.Elapsed = int(time.Since(start) / time.Millisecond) 103 | if trans.Response != nil { 104 | trace.Code = trans.Response.StatusCode 105 | } else { 106 | trace.Code = 403 107 | } 108 | 109 | trace.Method = r.Method 110 | 111 | fields := log.Ctx{ 112 | "code": trace.Code, 113 | "method": r.Method, 114 | "path": r.URL.Path, 115 | "elasped": trace.Elapsed, 116 | "user": trace.User, 117 | "groups": trace.Groups, 118 | "body": trace.Body, 119 | "access": trace.Access, 120 | } 121 | 122 | if err != nil { 123 | p.log.Error(trace.Error, fields) 124 | } else if trace.Code != 200 { 125 | p.log.Warn(trace.Message, fields) 126 | } else { 127 | p.log.Info(trace.Message, fields) 128 | } 129 | } 130 | 131 | func (t *traceTransport) RoundTrip(request *http.Request) (*http.Response, error) { 132 | res, err := http.DefaultTransport.RoundTrip(request) 133 | if err != nil { 134 | return res, err 135 | } 136 | 137 | if res.Header.Get("Content-Encoding") == "gzip" { 138 | body, err := gzip.NewReader(res.Body) 139 | if err != nil { 140 | return res, err 141 | } 142 | res.Body = body 143 | res.Header.Del("Content-Encoding") 144 | res.Header.Del("Content-Length") 145 | res.ContentLength = -1 146 | res.Uncompressed = true 147 | } 148 | 149 | t.Response = res 150 | 151 | return res, nil 152 | } 153 | 154 | func getBody(r *http.Request) ([]byte, error) { 155 | var body []byte 156 | buf, err := ioutil.ReadAll(r.Body) 157 | if err != nil { 158 | return body, err 159 | } 160 | rdr1 := ioutil.NopCloser(bytes.NewBuffer(buf)) 161 | body, err = ioutil.ReadAll(rdr1) 162 | if err != nil { 163 | return body, err 164 | } 165 | // If we don't keep a second reader untouched, we will consume 166 | // the request body when reading it 167 | rdr2 := ioutil.NopCloser(bytes.NewBuffer(buf)) 168 | // restore the body from the second reader 169 | r.Body = rdr2 170 | 171 | return body, nil 172 | } 173 | -------------------------------------------------------------------------------- /rbac.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | glob "github.com/ryanuber/go-glob" 8 | ) 9 | 10 | // Permissions structure for groups and users 11 | type Permissions struct { 12 | WhitelistedIndices []Index `yaml:"whitelisted_indices"` 13 | WhitelistedAPIs []API `yaml:"whitelisted_apis"` 14 | CanManage bool `yaml:"can_manage"` 15 | } 16 | 17 | // Index struct defines index and REST verbs allowed 18 | type Index struct { 19 | Name string 20 | RESTverbs []string `yaml:"rest_verbs"` 21 | } 22 | 23 | // API struct defines index and REST verbs allowed 24 | type API struct { 25 | Name string 26 | RESTverbs []string `yaml:"rest_verbs"` 27 | } 28 | 29 | type requestContext struct { 30 | trace *Trace 31 | r *http.Request 32 | C *Config 33 | body []byte 34 | whitelistedIndices []Index 35 | whitelistedIndicesNames string 36 | whitelistedAPIs []API 37 | indices []string 38 | firstPathComponent string 39 | } 40 | 41 | func getRequestContext(r *http.Request, C *Config, trace *Trace) (*requestContext, error) { 42 | body, err := getBody(r) 43 | if err != nil { 44 | return nil, err 45 | } 46 | bodyStr := string(body) 47 | trace.Body = bodyStr 48 | 49 | whitelistedIndices, err := getWhitelistedIndices(r, C) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | whitelistedAPIs, err := getWhitelistedAPIs(r, C) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | var indicesStrSlice []string 60 | for _, whitelistedIndex := range whitelistedIndices { 61 | indicesStrSlice = append(indicesStrSlice, whitelistedIndex.Name) 62 | } 63 | 64 | ctx := requestContext{ 65 | trace: trace, 66 | r: r, 67 | C: C, 68 | body: body, 69 | whitelistedIndices: whitelistedIndices, 70 | whitelistedAPIs: whitelistedAPIs, 71 | whitelistedIndicesNames: strings.Join(indicesStrSlice, ","), 72 | firstPathComponent: getFirstPathComponent(r), 73 | } 74 | 75 | return &ctx, nil 76 | } 77 | 78 | func (p *Prox) checkRBAC(ctx *requestContext) (bool, error) { 79 | 80 | user, err := getUser(ctx.r, ctx.C) 81 | if err != nil { 82 | return false, err 83 | } 84 | ctx.trace.User = user 85 | 86 | groups := getGroups(ctx.r, ctx.C) 87 | ctx.trace.Groups = groups 88 | 89 | ok, err := apiPermitted(ctx) 90 | if err != nil || !ok { 91 | return false, err 92 | } 93 | 94 | ok, err = indexPermitted(ctx) 95 | if err != nil || !ok { 96 | return false, err 97 | } 98 | 99 | return true, nil 100 | } 101 | 102 | func canManage(r *http.Request, C *Config) (bool, error) { 103 | groups := getGroups(r, C) 104 | 105 | // Can any of the groups manage? 106 | for _, group := range groups { 107 | if configGroup, ok := C.RBAC.Groups[group]; ok { 108 | if configGroup.CanManage == true { 109 | return true, nil 110 | } 111 | } 112 | } 113 | 114 | return false, nil 115 | } 116 | 117 | func getUser(r *http.Request, C *Config) (string, error) { 118 | // Username is trusted input provided by a SSO proxy layer 119 | var username string 120 | if _, ok := r.Header[C.UserHeaderName]; ok { 121 | username = r.Header[C.UserHeaderName][0] 122 | } 123 | 124 | return username, nil 125 | } 126 | 127 | func getGroups(r *http.Request, C *Config) []string { 128 | // Group is trusted input provided by a SSO proxy layer 129 | var groups = []string{C.AnonymousGroup} 130 | if _, ok := r.Header[C.GroupHeaderName]; ok { 131 | rawGroups := r.Header[C.GroupHeaderName][0] 132 | switch groupType := C.GroupHeaderType; groupType { 133 | case "AD": 134 | groups = getAdGroups(rawGroups) 135 | case "space-delimited": 136 | groups = getSpaceDelimitedGroups(rawGroups) 137 | default: 138 | groups = []string{C.AnonymousGroup} 139 | } 140 | } 141 | return groups 142 | } 143 | 144 | func getAdGroups(rawGroups string) []string { 145 | var groups []string 146 | splitKV := strings.Split(rawGroups, ",") 147 | for _, kv := range splitKV { 148 | splitSemiColins := strings.Split(kv, ";") 149 | for _, kv2 := range splitSemiColins { 150 | if strings.HasPrefix(kv2, "CN=") { 151 | newGroup := strings.ToLower(strings.TrimLeft(kv2, "CN=")) 152 | groups = append(groups, newGroup) 153 | } 154 | } 155 | } 156 | return groups 157 | } 158 | 159 | func getSpaceDelimitedGroups(rawGroups string) []string { 160 | groups := strings.Split(rawGroups, " ") 161 | return groups 162 | } 163 | 164 | func getWhitelistedIndices(r *http.Request, C *Config) ([]Index, error) { 165 | var indices []Index 166 | groups := getGroups(r, C) 167 | 168 | for _, group := range groups { 169 | if configGroup, ok := C.RBAC.Groups[group]; ok { 170 | for _, configIndex := range configGroup.WhitelistedIndices { 171 | indices = append(indices, configIndex) 172 | } 173 | } 174 | } 175 | 176 | return indices, nil 177 | } 178 | 179 | func getWhitelistedAPIs(r *http.Request, C *Config) ([]API, error) { 180 | var apis []API 181 | groups := getGroups(r, C) 182 | 183 | for _, group := range groups { 184 | if configGroup, ok := C.RBAC.Groups[group]; ok { 185 | for _, configAPI := range configGroup.WhitelistedAPIs { 186 | apis = append(apis, configAPI) 187 | } 188 | } 189 | } 190 | 191 | return apis, nil 192 | } 193 | 194 | func getFirstPathComponent(r *http.Request) string { 195 | return strings.Split(r.URL.Path, "/")[1] 196 | } 197 | 198 | func apiPermitted(ctx *requestContext) (bool, error) { 199 | api := extractAPI(ctx.r) 200 | 201 | if len(api) > 0 { 202 | for _, whitelistedAPI := range ctx.whitelistedAPIs { 203 | // match API patterns in the RBAC config against patterns 204 | // that were extracted (both support globs) 205 | if glob.Glob(whitelistedAPI.Name, api) { 206 | // also enforce REST verbs that are permitted on the API 207 | if stringInSlice(ctx.r.Method, whitelistedAPI.RESTverbs) { 208 | return true, nil 209 | } 210 | } 211 | } 212 | return false, nil 213 | } 214 | return true, nil 215 | } 216 | 217 | func indexPermitted(ctx *requestContext) (bool, error) { 218 | 219 | if ctx.firstPathComponent == "_all" || 220 | ctx.firstPathComponent == "_search" || 221 | ctx.firstPathComponent == "*" { 222 | mutatePath(ctx) 223 | } 224 | 225 | indices, err := extractIndices(ctx) 226 | if err != nil { 227 | return false, err 228 | } 229 | 230 | var allowedIndices []string 231 | 232 | // support searching wild card indices 233 | // req'd by Kibana Visual Builder 234 | // this implementation is gross 235 | for i, index := range indices { 236 | if index == "*" { 237 | err := mutateWildcardIndexInBody(ctx) 238 | if err != nil { 239 | return false, err 240 | } 241 | indices[i] = ctx.whitelistedIndicesNames 242 | allowedIndices = append(allowedIndices, ctx.whitelistedIndicesNames) 243 | } 244 | } 245 | 246 | // if this request operates on any indices, apply RBAC logic 247 | if len(indices) > 0 { 248 | for _, whitelistedIndex := range ctx.whitelistedIndices { 249 | for _, index := range indices { 250 | // match index patterns in the RBAC config against patterns 251 | // that were extracted (both support globs) 252 | if glob.Glob(whitelistedIndex.Name, index) { 253 | // also enforce REST verbs that are permitted on the index 254 | if stringInSlice(ctx.r.Method, whitelistedIndex.RESTverbs) { 255 | allowedIndices = append(allowedIndices, index) 256 | } 257 | } 258 | } 259 | } 260 | } else { 261 | return true, nil 262 | } 263 | 264 | if len(allowedIndices) >= len(indices) { 265 | return true, nil 266 | } 267 | return false, nil 268 | } 269 | 270 | func stringInSlice(a string, list []string) bool { 271 | for _, b := range list { 272 | if b == a { 273 | return true 274 | } 275 | } 276 | return false 277 | } 278 | -------------------------------------------------------------------------------- /rbac_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func getTestContext(path string, body string, method string) (*requestContext, error) { 12 | req, _ := http.NewRequest(method, "http://localhost:9200"+path, bytes.NewBufferString(body)) 13 | req.Header.Add("X-Remote-User", "dustind") 14 | req.Header.Add("X-Remote-Groups", "OU=thing,CN=group2,DC=something") 15 | 16 | var c Config 17 | c.getConf("config.example.yaml") 18 | 19 | var trace Trace 20 | 21 | ctx, err := getRequestContext(req, &c, &trace) 22 | 23 | return ctx, err 24 | } 25 | 26 | func TestIndexPermitted(t *testing.T) { 27 | body := `{"index":"*","ignore":[404],"timeout":"90s","requestTimeout":90000,"ignoreUnavailable":true} 28 | {"size":0,"query":{"bool":{"must":[{"range":{"@timestamp":{"gte":1519223869113,"lte":1519225669114,"format":"epoch_millis"}}},{"bool":{"must":[{"match_all":{}}],"must_not":[]}}]}},"aggs":{"61ca57f1-469d-11e7-af02-69e470af7417":{"filter":{"match_all":{}},"aggs":{"timeseries":{"date_histogram":{"field":"@timestamp","interval":"30s","min_doc_count":0,"time_zone":"America/Chicago","extended_bounds":{"min":1519223869113,"max":1519225669114}},"aggs":{"61ca57f2-469d-11e7-af02-69e470af7417":{"bucket_script":{"buckets_path":{"count":"_count"},"script":{"inline":"count * 1","lang":"expression"},"gap_policy":"skip"}}}}}}}} 29 | ` 30 | 31 | ctx, err := getTestContext("/*/_search", body, "GET") 32 | if err != nil { 33 | t.Error("could not get context: ", err) 34 | } 35 | 36 | ok, err := indexPermitted(ctx) 37 | if ok == false || err != nil { 38 | t.Error("index not permitted or err: ", err) 39 | } 40 | } 41 | 42 | func TestIndexNotPermitted(t *testing.T) { 43 | ctx, err := getTestContext("/secret_stuff/_search", "", "GET") 44 | if err != nil { 45 | t.Error("could not get context: ", err) 46 | } 47 | 48 | ok, err := indexPermitted(ctx) 49 | if ok == true || err != nil { 50 | t.Error("index permitted or err: ", err) 51 | } 52 | } 53 | 54 | func TestAPIPermitted(t *testing.T) { 55 | // GET to _nodes 56 | ctx, err := getTestContext("/_nodes/local", "", "GET") 57 | if err != nil { 58 | t.Error("could not get context: ", err) 59 | } 60 | ok, err := apiPermitted(ctx) 61 | if ok == false || err != nil { 62 | t.Error("API not permitted or err: ", err) 63 | } 64 | 65 | // PUT to _template 66 | ctx, err = getTestContext("/_template/kibana_index_template", "", "PUT") 67 | if err != nil { 68 | t.Error("could not get context: ", err) 69 | } 70 | if extractAPI(ctx.r) != "_template" { 71 | t.Error("Expected _template for API, got: ", extractAPI(ctx.r)) 72 | } 73 | ok, err = apiPermitted(ctx) 74 | if ok == false || err != nil { 75 | t.Error("API not permitted or err: ", err) 76 | } 77 | 78 | // DELETE to _template 79 | ctx, err = getTestContext("/_template/kibana_index_template", "", "DELETE") 80 | if err != nil { 81 | t.Error("could not get context: ", err) 82 | } 83 | ok, err = apiPermitted(ctx) 84 | if ok == true || err != nil { 85 | t.Error("API permitted or err: ", err) 86 | } 87 | } 88 | 89 | func TestAPINotPermitted(t *testing.T) { 90 | ctx, err := getTestContext("/test_deflek/_settings", "", "GET") 91 | if err != nil { 92 | t.Error("could not get context: ", err) 93 | } 94 | 95 | ok, err := apiPermitted(ctx) 96 | if ok || err != nil { 97 | t.Error("API permitted or err: ", err) 98 | } 99 | } 100 | 101 | func TestGetWhitelistedIndices(t *testing.T) { 102 | 103 | expectedIndices := []Index{ 104 | Index{Name: "test_deflek", 105 | RESTverbs: []string{ 106 | "GET", "POST", 107 | }}, 108 | Index{Name: "test_deflek2", 109 | RESTverbs: []string{ 110 | "GET", 111 | }}, 112 | Index{Name: "globby-*", 113 | RESTverbs: []string{ 114 | "GET", 115 | }}, 116 | Index{Name: ".kibana", 117 | RESTverbs: []string{ 118 | "GET", "POST", 119 | }}, 120 | } 121 | 122 | ctx, err := getTestContext("/", "", "GET") 123 | if err != nil { 124 | t.Error("could not get context: ", err) 125 | } 126 | 127 | extractedIndices, err := getWhitelistedIndices(ctx.r, ctx.C) 128 | if err != nil { 129 | t.Error("got error while getting whitelisted indices: ", err) 130 | } 131 | 132 | if diff := cmp.Diff(expectedIndices, extractedIndices); diff != "" { 133 | t.Errorf("unexpected difference: (-got +want)\n%s", diff) 134 | } 135 | } 136 | 137 | func TestGetUser(t *testing.T) { 138 | ctx, err := getTestContext("/", "", "GET") 139 | if err != nil { 140 | t.Error("could not get context: ", err) 141 | } 142 | user, err := getUser(ctx.r, ctx.C) 143 | if err != nil { 144 | t.Error("could not get user: ", err) 145 | } 146 | 147 | if user != "dustind" { 148 | t.Errorf("got %s, expected %s", user, "dustind") 149 | 150 | } 151 | } 152 | 153 | func TestCheckRBAC(t *testing.T) { 154 | body := `{"index":"*","ignore":[404],"timeout":"90s","requestTimeout":90000,"ignoreUnavailable":true} 155 | {"size":0,"query":{"bool":{"must":[{"range":{"@timestamp":{"gte":1519223869113,"lte":1519225669114,"format":"epoch_millis"}}},{"bool":{"must":[{"match_all":{}}],"must_not":[]}}]}},"aggs":{"61ca57f1-469d-11e7-af02-69e470af7417":{"filter":{"match_all":{}},"aggs":{"timeseries":{"date_histogram":{"field":"@timestamp","interval":"30s","min_doc_count":0,"time_zone":"America/Chicago","extended_bounds":{"min":1519223869113,"max":1519225669114}},"aggs":{"61ca57f2-469d-11e7-af02-69e470af7417":{"bucket_script":{"buckets_path":{"count":"_count"},"script":{"inline":"count * 1","lang":"expression"},"gap_policy":"skip"}}}}}}}} 156 | ` 157 | 158 | ctx, err := getTestContext("/*/_search", body, "GET") 159 | if err != nil { 160 | t.Error("could not get context: ", err) 161 | } 162 | 163 | var p Prox 164 | 165 | ok, err := p.checkRBAC(ctx) 166 | if !ok || err != nil { 167 | t.Error("index not permitted or err: ", err) 168 | } 169 | } 170 | 171 | func TestCanManage(t *testing.T) { 172 | ctx, err := getTestContext("/foo", "", "GET") 173 | if err != nil { 174 | t.Error("could not get context: ", err) 175 | } 176 | 177 | ok, err := canManage(ctx.r, ctx.C) 178 | if !ok || err != nil { 179 | t.Error("should be able to manage or error but got: ", ok) 180 | } 181 | } 182 | 183 | func indexInSlice(a Index, indices []Index) bool { 184 | for _, b := range indices { 185 | if b.Name == a.Name { 186 | return true 187 | } 188 | } 189 | return false 190 | } 191 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | CGO_ENABLED=0 GOOS=`go env GOHOSTOS` GOARCH=`go env GOHOSTARCH` go build -o app 6 | docker build -t deflek -f Dockerfile.local . 7 | docker run -it --rm -v /:/host deflek 8 | --------------------------------------------------------------------------------