Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
52 changes: 52 additions & 0 deletions shock-server/controller/node/multi.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package node

import (
"encoding/json"
"fmt"
"github.com/MG-RAST/Shock/shock-server/conf"
e "github.com/MG-RAST/Shock/shock-server/errors"
Expand Down Expand Up @@ -101,6 +102,28 @@ func (cr *NodeController) ReadMany(w http.ResponseWriter, r *http.Request) {
}
}
}
} else if queryJSON, ok := query["querymongo"]; ok {
rawJSON := queryJSON[0]
if len(rawJSON) > maxQueryJSONSize {
err_msg := fmt.Sprintf("err@node_ReadMany: querymongo JSON exceeds maximum size of %d bytes", maxQueryJSONSize)
logger.Error(err_msg)
responder.RespondWithError(w, r, http.StatusBadRequest, err_msg)
return
}
var userQuery bson.M
if err := json.Unmarshal([]byte(rawJSON), &userQuery); err != nil {
err_msg := "err@node_ReadMany: invalid JSON in querymongo parameter: " + err.Error()
logger.Error(err_msg)
responder.RespondWithError(w, r, http.StatusBadRequest, err_msg)
return
}
if err := sanitizeQuery(userQuery); err != nil {
err_msg := "err@node_ReadMany: " + err.Error()
logger.Error(err_msg)
responder.RespondWithError(w, r, http.StatusBadRequest, err_msg)
return
}
qOpts = userQuery
Comment on lines +113 to +126
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userQuery is declared as bson.M (a named map type), but sanitizeQuery expects map[string]interface{}. In Go, a named map type isn’t directly assignable to an unnamed map[...]..., so this call won’t compile. Fix by either changing sanitizeQuery to accept bson.M (and updating tests accordingly) or by explicitly converting at the call site (e.g., converting userQuery to map[string]interface{}) / making sanitizeQuery accept an interface{} and handle both map types.

Copilot uses AI. Check for mistakes.
}
Comment on lines +105 to 127
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new querymongo passthrough path in ReadMany has no controller-level tests to verify request validation behavior (e.g., invalid JSON returns 400, oversized JSON hits the 16KB limit) and that the handler short-circuits before DB access. Adding httptest-based coverage similar to node_controller_test.go would help prevent regressions for this new public API surface.

Copilot uses AI. Check for mistakes.

if len(OptsMArray) > 0 {
Expand Down Expand Up @@ -372,3 +395,32 @@ func parseTypedValue(i *interface{}) {
}
return
}

var blockedOperators = map[string]bool{
"$where": true, "$expr": true, "$function": true, "$accumulator": true,
}

const maxQueryJSONSize = 16 * 1024

func sanitizeQuery(m map[string]interface{}) error {
for key, val := range m {
if blockedOperators[key] {
return fmt.Errorf("operator %s is not allowed in passthrough queries", key)
}
switch v := val.(type) {
case map[string]interface{}:
if err := sanitizeQuery(v); err != nil {
return err
}
case []interface{}:
for _, item := range v {
if subMap, ok := item.(map[string]interface{}); ok {
if err := sanitizeQuery(subMap); err != nil {
return err
}
}
}
}
}
return nil
}
112 changes: 112 additions & 0 deletions shock-server/controller/node/multi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package node

import (
"testing"
)

func TestSanitizeQuery_AllowsSafeOperators(t *testing.T) {
tests := []struct {
name string
query map[string]interface{}
}{
{
name: "simple field match",
query: map[string]interface{}{"file.name": "test.fasta"},
},
{
name: "$gt operator",
query: map[string]interface{}{"file.size": map[string]interface{}{"$gt": 1000}},
},
{
name: "$in operator",
query: map[string]interface{}{"file.name": map[string]interface{}{"$in": []interface{}{"a", "b"}}},
},
{
name: "$exists operator",
query: map[string]interface{}{"attributes.project": map[string]interface{}{"$exists": true}},
},
{
name: "$and with $elemMatch",
query: map[string]interface{}{
"$and": []interface{}{
map[string]interface{}{"file.size": map[string]interface{}{"$gt": 0}},
map[string]interface{}{"tags": map[string]interface{}{"$elemMatch": map[string]interface{}{"$eq": "metagenome"}}},
},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := sanitizeQuery(tt.query); err != nil {
t.Errorf("sanitizeQuery() returned unexpected error: %v", err)
}
})
}
}

func TestSanitizeQuery_BlocksDangerousOperators(t *testing.T) {
tests := []struct {
name string
query map[string]interface{}
wantOp string
}{
{
name: "$where at top level",
query: map[string]interface{}{"$where": "this.file.size > 1000"},
wantOp: "$where",
},
{
name: "$expr at top level",
query: map[string]interface{}{"$expr": map[string]interface{}{"$gt": []interface{}{"$file.size", 1000}}},
wantOp: "$expr",
},
{
name: "$function at top level",
query: map[string]interface{}{"$function": map[string]interface{}{"body": "function() { return true; }"}},
wantOp: "$function",
},
{
name: "$accumulator at top level",
query: map[string]interface{}{"$accumulator": map[string]interface{}{"init": "function() {}"}},
wantOp: "$accumulator",
},
{
name: "$where nested inside $or",
query: map[string]interface{}{
"$or": []interface{}{
map[string]interface{}{"file.name": "test"},
map[string]interface{}{"$where": "this.file.size > 0"},
},
},
wantOp: "$where",
},
{
name: "$expr nested inside $and inside $or",
query: map[string]interface{}{
"$or": []interface{}{
map[string]interface{}{
"$and": []interface{}{
map[string]interface{}{"$expr": map[string]interface{}{"$gt": []interface{}{"$a", "$b"}}},
},
},
},
},
wantOp: "$expr",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := sanitizeQuery(tt.query)
if err == nil {
t.Errorf("sanitizeQuery() expected error for operator %s, got nil", tt.wantOp)
return
}
expected := "operator " + tt.wantOp + " is not allowed in passthrough queries"
if err.Error() != expected {
t.Errorf("sanitizeQuery() error = %q, want %q", err.Error(), expected)
}
})
}
}