Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions dgraph/cmd/alpha/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,7 @@ func queryHandler(w http.ResponseWriter, r *http.Request) {
}

ctx := context.WithValue(r.Context(), query.DebugKey, isDebugMode)
ctx = x.AttachAccessJwt(ctx, r)
ctx = x.AttachRemoteIP(ctx, r)
ctx = x.AttachRequestIdentity(ctx, r)

if queryTimeout != 0 {
var cancel context.CancelFunc
Expand Down Expand Up @@ -467,7 +466,7 @@ func mutationHandler(w http.ResponseWriter, r *http.Request) {
req.Hash = hash
req.CommitNow = commitNow

ctx := x.AttachAccessJwt(context.Background(), r)
ctx := x.AttachRequestIdentity(context.Background(), r)
resp, err := (&edgraph.Server{}).QueryNoGrpc(ctx, req)
if err != nil {
x.SetStatusWithData(w, x.ErrorInvalidRequest, err.Error())
Expand Down Expand Up @@ -530,7 +529,7 @@ func commitHandler(w http.ResponseWriter, r *http.Request) {
return
}

ctx := x.AttachAccessJwt(context.Background(), r)
ctx := x.AttachRequestIdentity(context.Background(), r)
var response map[string]interface{}
if abort {
response, err = handleAbort(ctx, startTs, hash)
Expand Down
41 changes: 37 additions & 4 deletions dgraph/cmd/alpha/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,18 @@ they form a Raft group and provide synchronous replication.
"A comma separated list of IP addresses, IP ranges, CIDR blocks, or hostnames you wish "+
"to whitelist for performing admin actions (i.e., --security "+
`"whitelist=144.142.126.254,127.0.0.1:127.0.0.3,192.168.0.0/16,host.docker.`+
`internal").`).
`internal"). Empty by default, which admits loopback only. This is a network `+
"location check, NOT authentication: every address in the range can run "+
"privileged operations without a credential unless you also set token= or enable "+
"ACL.").
Flag("anonymous",
"[full, data, none] What a caller that presents no verified credential may do. "+
"full (default) leaves authorization to whatever the whitelist, token, and ACL "+
"settings decide, which is the behavior of every earlier release. data allows "+
"queries, mutations, commits, and login while denying every administrative "+
"operation regardless of the whitelist. none additionally denies queries, "+
"mutations, and commits, leaving only login and the health endpoints. data and "+
"none require token= or ACL, otherwise no caller can ever be identified.").
String())

flag.String("limit", worker.LimitDefaults, z.NewSuperFlagHelp(worker.LimitDefaults).
Expand Down Expand Up @@ -310,7 +321,10 @@ func healthCheck(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

ctx := x.AttachAccessJwt(context.Background(), r)
// Full identity, not just the access JWT: Health(all) is a capability check,
// and under a closed --security "anonymous=..." posture a caller presenting
// only the --security token would otherwise arrive unidentified.
ctx := x.AttachRequestIdentity(context.Background(), r)
var resp *api.Response
if resp, err = (&edgraph.Server{}).Health(ctx, true); err != nil {
x.SetStatus(w, x.Error, err.Error())
Expand Down Expand Up @@ -355,8 +369,9 @@ func stateHandler(w http.ResponseWriter, r *http.Request) {
x.AddCorsHeaders(w)
w.Header().Set("Content-Type", "application/json")

ctx := context.Background()
ctx = x.AttachAccessJwt(ctx, r)
// Full identity, not just the access JWT: State is a capability check. See the
// note on the /health?all branch above.
ctx := x.AttachRequestIdentity(context.Background(), r)

var aResp *api.Response
if aResp, err = (&edgraph.Server{}).State(ctx); err != nil {
Expand Down Expand Up @@ -652,6 +667,8 @@ func run() {
FromSuperFlag(Alpha.Conf.GetString("badger"))
security := z.NewSuperFlag(Alpha.Conf.GetString("security")).MergeAndCheckDefault(
worker.SecurityDefaults)
anonymous, err := x.ParseAnonymousPosture(security.GetString("anonymous"))
x.Check(err)
conf := audit.GetAuditConf(Alpha.Conf.GetString("audit"))

x.Config.Limit = z.NewSuperFlag(Alpha.Conf.GetString("limit")).MergeAndCheckDefault(
Expand Down Expand Up @@ -703,6 +720,11 @@ func run() {
ips, err := getIPsFromString(security.GetString("whitelist"))
x.Check(err)

for _, msg := range securityWarnings(anonymous, security.GetString("whitelist"), ips,
opts.AuthToken, keys.AclSecretKey != nil, httpPort()) {
glog.Warning(msg)
}

tlsClientConf, err := x.LoadClientTLSConfigForInternalPort(Alpha.Conf)
x.Check(err)
tlsServerConf, err := x.LoadServerTLSConfigForInternalPort(Alpha.Conf)
Expand All @@ -720,6 +742,7 @@ func run() {
AbortOlderThan: abortDur,
StartTime: startTime,
Security: security,
Anonymous: anonymous,
TLSClientConfig: tlsClientConf,
TLSServerConfig: tlsServerConf,
AclJwtAlg: keys.AclJwtAlg,
Expand All @@ -729,11 +752,21 @@ func run() {
}
x.WorkerConfig.Parse(Alpha.Conf)

// The built-in authenticator: ACL's access JWT, plus the --security auth token as
// an identity rather than a boolean check. Installed before ConfigureIdentity so a
// deployment can compose with or replace it; see edgraph.PresharedAuthenticator.
x.SetAuthenticator(edgraph.PresharedAuthenticator())

// Install deployment-specific authentication and authorization now: the config
// is parsed, and nothing is serving yet. A misconfiguration here is fatal, which
// is why it runs before any listener rather than lazily on the first request.
ConfigureIdentity()

// The --security "anonymous=..." floor goes on last, so that it wraps whatever
// policy ConfigureIdentity installed rather than being replaced by it. Under the
// shipped anonymous=full this installs nothing.
edgraph.EnforceAnonymousPosture(anonymous)

// Set the directory for temporary buffers.
z.SetTmpDir(x.WorkerConfig.TmpDir)

Expand Down
63 changes: 63 additions & 0 deletions dgraph/cmd/alpha/security_posture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package alpha

import (
"fmt"

"github.com/dgraph-io/dgraph/v25/x"
)

// securityWarnings returns the startup warnings for a --security configuration
// whose parts do not add up, or nil when they do.
//
// Three controls gate Alpha's privileged operations -- the whitelist, the auth
// token, and ACL -- and each one passes when its own feature is unconfigured. The
// protection an operator gets is whatever they configured rather than the union of
// the three, and the two combinations below are the ones where that produces an
// outcome they almost certainly did not intend.
//
// It is pure and returns strings rather than logging, so the combinations can be
// pinned by a table test.
func securityWarnings(posture x.AnonymousPosture, whitelist string, ips []x.IPRange,
authToken string, aclEnabled bool, httpPort int) []string {

hasCredential := aclEnabled || authToken != ""
var out []string

// The shipped default is an empty whitelist, which admits loopback only, so the
// admin plane does not leave the host without an operator widening it. This
// warns at exactly that point: whitelisting answers where a request came from
// and has no credential in it, so a widened range with nothing else configured
// means every address inside it can run privileged operations anonymously.
if posture == x.AnonymousFull && len(ips) > 0 && !hasCredential {
Comment thread
matthewmcneely marked this conversation as resolved.
Outdated
out = append(out, fmt.Sprintf(
`SECURITY: --security "whitelist=%s" admits non-loopback callers, but neither ACL nor `+
`an admin token is configured. Privileged operations (backup, restore, export, `+
`shutdown, removeNode, moveTablet, assign, draining, config, namespace create and `+
`drop) are reachable from that range WITHOUT ANY CREDENTIAL: anyone who can reach `+
`port %d can read the whole database via backup or export, restore over it, or shut `+
`the cluster down. Set --security "token=..." or enable ACL, and narrow the `+
`whitelist to the addresses that actually administer this cluster. `+
`--security "anonymous=data" additionally denies every administrative operation to `+
`a caller that presents no credential.`, whitelist, httpPort))
}

// A closed posture with nothing that can produce an identity. Every capability
// check will deny, including the operator's own, so the cluster cannot be
// administered at all. This is a misconfiguration rather than a hardening, and
// it is worth saying so loudly at boot instead of letting it surface as a
// permission error during an incident.
if posture.RequiresIdentityForCapability() && !hasCredential {
out = append(out, fmt.Sprintf(
`SECURITY: --security "anonymous=%s" requires an identified caller, but neither ACL nor `+
`an admin token is configured, so no request can ever be identified. Every `+
`administrative operation will be denied, including from loopback, and this cluster `+
`cannot be administered. Set --security "token=..." or enable ACL.`, posture))
Comment thread
matthewmcneely marked this conversation as resolved.
Outdated
}

return out
}
205 changes: 205 additions & 0 deletions dgraph/cmd/alpha/security_posture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package alpha

import (
"go/ast"
"go/parser"
gotoken "go/token"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/dgraph-io/dgraph/v25/x"
)

func TestSecurityWarnings(t *testing.T) {
tests := []struct {
name string
posture x.AnonymousPosture
whitelist string
authToken string
acl bool
want []string // substrings that must appear, one per expected warning
}{{
// The shipped default. An empty whitelist admits loopback only, so the admin
// plane has not left the host and there is nothing to say.
name: "stock alpha is quiet",
posture: x.AnonymousFull,
}, {
name: "widened whitelist with no credential",
posture: x.AnonymousFull,
whitelist: "0.0.0.0/0",
want: []string{"WITHOUT ANY CREDENTIAL"},
}, {
// A plausible-looking CIDR is still every pod in the cluster.
name: "private CIDR with no credential",
posture: x.AnonymousFull,
whitelist: "10.0.0.0/8",
want: []string{"WITHOUT ANY CREDENTIAL"},
}, {
name: "widened whitelist with a token",
posture: x.AnonymousFull,
whitelist: "0.0.0.0/0",
authToken: "s3cr3t",
}, {
name: "widened whitelist with ACL",
posture: x.AnonymousFull,
whitelist: "0.0.0.0/0",
acl: true,
}, {
// The closed posture answers the exposure, so the first warning must stand
// down rather than tell an operator to fix something they already fixed.
name: "widened whitelist with a closed posture",
posture: x.AnonymousData,
whitelist: "0.0.0.0/0",
authToken: "s3cr3t",
}, {
// A closed posture with nothing that can produce an identity. Every
// capability check denies, including the operator's own.
name: "closed posture with no credential at all",
posture: x.AnonymousData,
want: []string{"cannot be administered"},
}, {
name: "anonymous=none with no credential at all",
posture: x.AnonymousNone,
whitelist: "0.0.0.0/0",
want: []string{"cannot be administered"},
}, {
name: "closed posture with ACL is fine",
posture: x.AnonymousNone,
whitelist: "0.0.0.0/0",
acl: true,
}}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ips, err := getIPsFromString(tt.whitelist)
require.NoError(t, err)

got := securityWarnings(tt.posture, tt.whitelist, ips, tt.authToken, tt.acl, 8080)
require.Len(t, got, len(tt.want))
for i, want := range tt.want {
require.Contains(t, got[i], "SECURITY:")
require.Contains(t, got[i], want)
}
})
}
}

// TestDefaultWhitelistAdmitsLoopbackOnly pins the model the v25 default rests on:
// a stock `dgraph alpha`, with no --security flag, serves admin operations to this
// host and nothing else. That is what makes anonymous=full a defensible default,
// and what makes widening the whitelist the moment the admin plane leaves the
// host.
func TestDefaultWhitelistAdmitsLoopbackOnly(t *testing.T) {
prev := x.WorkerConfig.WhiteListedIPRanges
t.Cleanup(func() { x.WorkerConfig.WhiteListedIPRanges = prev })

ips, err := getIPsFromString("")
require.NoError(t, err)
x.WorkerConfig.WhiteListedIPRanges = ips

for _, ip := range []string{"127.0.0.1", "::1"} {
require.Truef(t, x.IsIpWhitelisted(ip), "loopback %s must reach admin operations", ip)
}
for _, ip := range []string{
"203.0.113.7", // arbitrary remote host
"172.17.0.1", // Docker bridge, i.e. the host reaching a published port
"10.0.5.7", // private network peer
"192.168.1.20", // LAN peer
} {
require.Falsef(t, x.IsIpWhitelisted(ip),
"non-loopback %s must not reach admin operations by default", ip)
}
}

// identityExceptions names the handlers that deliberately assemble the request
// context by hand, and why. Everything else must go through
// x.AttachRequestIdentity.
var identityExceptions = map[string]string{
// Login is how an access JWT is obtained, so it must not depend on one being
// present, and it needs no Principal of its own: edgraph.Login authorizes on
// hasAdminAuth, which reads the peer and the auth token straight out of the
// metadata this prelude attaches.
"loginHandler": "login must not require a credential it is the means of issuing",
}

// TestHTTPEdgeResolvesIdentityThroughOneHelper pins an invariant that a live test
// caught the hard way.
//
// x.AttachRequestIdentity is the whole HTTP-edge prelude: access JWT, remote IP,
// auth token, then identity resolution. A handler that reaches for the individual
// pieces instead gets some of them, and the one it is most likely to omit is the
// auth token -- which is invisible until a closed --security "anonymous=..."
// posture is configured, at which point a caller presenting the token arrives
// unidentified and is refused. /state and /health?all both had exactly that shape.
//
// So: no handler in this package resolves identity by hand unless it is listed
// above with a reason.
func TestHTTPEdgeResolvesIdentityThroughOneHelper(t *testing.T) {
banned := map[string]string{
"AttachAccessJwt": "attaches the access JWT but not the --security auth token",
"AttachAuthToken": "attaches the auth token but resolves no Principal",
"AttachRemoteIP": "attaches the peer but resolves no Principal",
}

// Every non-test file in the package, regardless of build tags. parser.ParseDir is
// deprecated, and ignoring tags is what this test wants anyway: a handler compiled
// only on one platform is still a handler.
paths, err := filepath.Glob("*.go")
require.NoError(t, err)

// Aliased: the integration-tagged run_test.go declares a package-level `token`.
fset := gotoken.NewFileSet()
seenExceptions := map[string]bool{}
for _, path := range paths {
if strings.HasSuffix(path, "_test.go") {
continue
}
file, err := parser.ParseFile(fset, path, nil, 0)
require.NoError(t, err)

for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
_, exempt := identityExceptions[fn.Name.Name]
ast.Inspect(fn, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
pkgIdent, ok := sel.X.(*ast.Ident)
if !ok || pkgIdent.Name != "x" {
return true
}
why, bad := banned[sel.Sel.Name]
if !bad {
return true
}
if exempt {
seenExceptions[fn.Name.Name] = true
return true
}
t.Errorf("%s: %s calls x.%s, which %s. Use x.AttachRequestIdentity, or add "+
"it to identityExceptions with a reason.",
fset.Position(sel.Pos()), fn.Name.Name, sel.Sel.Name, why)
return true
})
}
}

// A stale exception is its own problem: it reads as a documented carve-out for
// something that no longer exists.
for name := range identityExceptions {
require.Truef(t, seenExceptions[name],
"identityExceptions lists %q, but it no longer assembles the context by hand", name)
}
}
Loading
Loading