fix(auth): tighten bridge claim protocol
This commit is contained in:
@@ -41,15 +41,15 @@ type request struct {
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Version int `json:"version"`
|
||||
OK bool `json:"ok"`
|
||||
Created bool `json:"created,omitempty"`
|
||||
Replaced bool `json:"replaced,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
Found bool `json:"found,omitempty"`
|
||||
Claimed bool `json:"claimed,omitempty"`
|
||||
ContentBase64 string `json:"contentBase64,omitempty"`
|
||||
Entries []safeio.PrivateDirectoryEntry `json:"entries,omitempty"`
|
||||
Version int `json:"version"`
|
||||
OK bool `json:"ok"`
|
||||
Created bool `json:"created,omitempty"`
|
||||
Replaced bool `json:"replaced,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
Found bool `json:"found,omitempty"`
|
||||
Claimed bool `json:"claimed,omitempty"`
|
||||
ContentBase64 string `json:"contentBase64,omitempty"`
|
||||
Entries *[]safeio.PrivateDirectoryEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
// Run accepts exactly one strict JSON request on stdin and emits exactly one JSON response on
|
||||
@@ -96,7 +96,7 @@ func fail(stderr io.Writer) int {
|
||||
}
|
||||
|
||||
func execute(input request) (response, error) {
|
||||
if input.Version != protocolVersion || !validDirectory(input.Directory) {
|
||||
if input.Version != protocolVersion || !validDirectory(input.Directory) || !validOperationShape(input) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
directory, err := storageDirectory(input.Root, input.Directory)
|
||||
@@ -106,7 +106,7 @@ func execute(input request) (response, error) {
|
||||
switch input.Operation {
|
||||
case "create":
|
||||
contents, err := decodeContents(input)
|
||||
if err != nil || !digestFilename.MatchString(input.Filename) {
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
created, err := createPrivate(directory, input.Filename, contents)
|
||||
@@ -115,9 +115,6 @@ func execute(input request) (response, error) {
|
||||
}
|
||||
return response{Version: protocolVersion, OK: true, Created: created}, nil
|
||||
case "read":
|
||||
if !digestFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
contents, found, err := readPrivate(directory, input.Filename, recordMaximum(input.Directory))
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
@@ -125,7 +122,7 @@ func execute(input request) (response, error) {
|
||||
return contentResponse(found, contents), nil
|
||||
case "replace":
|
||||
contents, err := decodeContents(input)
|
||||
if err != nil || !digestFilename.MatchString(input.Filename) {
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
if err := safeio.ReplaceCanonicalRegular(filepath.Join(directory, input.Filename), contents, 0o600); err != nil {
|
||||
@@ -133,9 +130,6 @@ func execute(input request) (response, error) {
|
||||
}
|
||||
return response{Version: protocolVersion, OK: true, Replaced: true}, nil
|
||||
case "remove":
|
||||
if !digestFilename.MatchString(input.Filename) && !claimFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
removed, err := removePrivate(directory, input.Filename)
|
||||
if err != nil {
|
||||
return response{}, errInvalid
|
||||
@@ -147,20 +141,14 @@ func execute(input request) (response, error) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !digestFilename.MatchString(entry.Name) && !claimFilename.MatchString(entry.Name) {
|
||||
if !digestFilename.MatchString(entry.Name) && !(input.Directory == "oidc" && claimFilename.MatchString(entry.Name)) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
}
|
||||
return response{Version: protocolVersion, OK: true, Entries: entries}, nil
|
||||
return response{Version: protocolVersion, OK: true, Entries: &entries}, nil
|
||||
case "claim-consume":
|
||||
if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
return claimConsume(directory, input.Filename)
|
||||
case "read-claim":
|
||||
if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
contents, found, err := safeio.ReadCanonicalPrivateClaim(
|
||||
filepath.Join(directory, input.Filename),
|
||||
filepath.Join(directory, asClaimFilename(input.Filename)),
|
||||
@@ -171,9 +159,6 @@ func execute(input request) (response, error) {
|
||||
}
|
||||
return contentResponse(found, contents), nil
|
||||
case "remove-claim":
|
||||
if input.Directory != "oidc" || !digestFilename.MatchString(input.Filename) {
|
||||
return response{}, errInvalid
|
||||
}
|
||||
removed, err := safeio.RemoveCanonicalPrivateClaim(
|
||||
filepath.Join(directory, input.Filename),
|
||||
filepath.Join(directory, asClaimFilename(input.Filename)),
|
||||
@@ -187,6 +172,24 @@ func execute(input request) (response, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func validOperationShape(input request) bool {
|
||||
noContents := input.ContentBase64 == ""
|
||||
switch input.Operation {
|
||||
case "create", "replace":
|
||||
return digestFilename.MatchString(input.Filename)
|
||||
case "read":
|
||||
return noContents && digestFilename.MatchString(input.Filename)
|
||||
case "remove":
|
||||
return noContents && (digestFilename.MatchString(input.Filename) || (input.Directory == "oidc" && claimFilename.MatchString(input.Filename)))
|
||||
case "list":
|
||||
return input.Filename == "" && noContents
|
||||
case "claim-consume", "read-claim", "remove-claim":
|
||||
return input.Directory == "oidc" && noContents && digestFilename.MatchString(input.Filename)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func contentResponse(found bool, contents []byte) response {
|
||||
if !found {
|
||||
return response{Version: protocolVersion, OK: true}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestProtocolCreatesReadsReplacesListsAndRemovesPrivateRecord(t *testing.T)
|
||||
t.Fatal("replace did not report success")
|
||||
}
|
||||
listed := runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
||||
if len(listed.Entries) != 1 || listed.Entries[0].Name != filename {
|
||||
if listed.Entries == nil || len(*listed.Entries) != 1 || (*listed.Entries)[0].Name != filename {
|
||||
t.Fatalf("list = %#v, want exactly %q", listed.Entries, filename)
|
||||
}
|
||||
removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: filename})
|
||||
@@ -61,6 +61,49 @@ func TestProtocolListSerializesLowerCamelBridgeDTO(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolListAlwaysSerializesAnEmptyEntriesArray(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
input, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := Run(context.Background(), nil, bytes.NewReader(input), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("Run() code = %d stderr = %q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"entries":[]`) {
|
||||
t.Fatalf("empty list bridge JSON = %q, want entries array", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolPermitsClaimNamesOnlyForOIDCRemove(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
digest := "abababababababababababababababababababababababababababababababab.json"
|
||||
claim := strings.TrimSuffix(digest, ".json") + ".claim"
|
||||
for _, directory := range []string{"sessions", "oidc"} {
|
||||
runRequest(t, request{Version: 1, Operation: "list", Root: root, Directory: directory})
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "sessions", claim), []byte("orphan"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(root, "oidc", claim), []byte("orphan"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "sessions", Filename: claim})
|
||||
runRejected(t, request{Version: 1, Operation: "list", Root: root, Directory: "sessions", Filename: claim})
|
||||
runRejected(t, request{Version: 1, Operation: "read", Root: root, Directory: "oidc", Filename: claim})
|
||||
runRejected(t, request{Version: 1, Operation: "create", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
||||
runRejected(t, request{Version: 1, Operation: "replace", Root: root, Directory: "oidc", Filename: claim, ContentBase64: base64.StdEncoding.EncodeToString([]byte("record"))})
|
||||
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: "../" + claim})
|
||||
runRejected(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: strings.TrimSuffix(claim, ".claim") + ".claim.bak"})
|
||||
|
||||
removed := runRequest(t, request{Version: 1, Operation: "remove", Root: root, Directory: "oidc", Filename: claim})
|
||||
if !removed.Removed {
|
||||
t.Fatal("OIDC orphan claim removal did not report success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsProtocolOverflowAndTrailingJSONValues(t *testing.T) {
|
||||
root := filepath.Join(privateTestRoot(t), "auth")
|
||||
valid, err := json.Marshal(request{Version: 1, Operation: "list", Root: root, Directory: "sessions"})
|
||||
|
||||
Reference in New Issue
Block a user