Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Vega] Clicking on an internal url (href redirection) doesn't work #6285

Open
YANG-DB opened this issue Mar 28, 2024 · 8 comments
Open

[Vega] Clicking on an internal url (href redirection) doesn't work #6285

YANG-DB opened this issue Mar 28, 2024 · 8 comments
Assignees
Labels
bug Something isn't working

Comments

@YANG-DB
Copy link
Member

YANG-DB commented Mar 28, 2024

Describe the bug

If I use a vega spec that allows the user to click (navigate) to another internal url (e.g. a dashboard), the redirect doesn't work.

To Reproduce
Using the following vega spec:

{
  "$schema": "https://vega.github.io/schema/vega/v5.json",
  "padding": {"left": 0, "right": 0, "top": 0, "bottom": 0},
  "autosize": "pad",
  "background": "#f4f7ff",
  "signals": [

    {"name": "xrange", "update": "[0, width]"},
    {"name": "yrange", "update": "[height, 0]"},
    {"name": "xext", "update": "[0, width]"},
    {"name": "yext", "update": "[height, 0]"},
    {
      "name": "down",
      "value": null,
      "on": [
        {"events": "mouseup,touchend", "update": "null"},
        {"events": "mousedown, touchstart", "update": "xy()"},
        {"events": "symbol:mousedown, symbol:touchstart", "update": "null"}
      ]
    },
    {
      "name": "xcur",
      "value": null,
      "on": [{"events": "mousedown, touchstart, touchend", "update": "xdom"}]
    },
    {
      "name": "ycur",
      "value": null,
      "on": [{"events": "mousedown, touchstart, touchend", "update": "ydom"}]
    },
    {
      "name": "delta",
      "value": [0, 0],
      "on": [
        {
          "events": [
            {
              "source": "window",
              "type": "mousemove",
              "consume": true,
              "between": [
                {"type": "mousedown"},
                {"source": "window", "type": "mouseup"}
              ]
            },
            {
              "type": "touchmove",
              "consume": true,
              "filter": "event.touches.length === 1"
            }
          ],
          "update": "down ? [down[0]-x(), y()-down[1]] : [0,0]"
        }
      ]
    },
    {
      "name": "anchor",
      "value": [0, 0],
      "on": [
        {
          "events": "wheel",
          "update": "[invert('xscale', x()), invert('yscale', y())]"
        },
        {
          "events": {
            "type": "touchstart",
            "filter": "event.touches.length===2"
          },
          "update": "[(xdom[0] + xdom[1]) / 2, (ydom[0] + ydom[1]) / 2]"
        }
      ]
    },
    {
      "name": "zoom",
      "value": 1,
      "on": [
        {
          "events": "wheel!",
          "force": true,
          "update": "pow(1.001, event.deltaY * pow(16, event.deltaMode))"
        },
        {
          "events": {"signal": "dist2"},
          "force": true,
          "update": "dist1 / dist2"
        },
        {"events": [{"source": "view", "type": "dblclick"}], "update": "1"}
      ]
    },
    {
      "name": "dist1",
      "value": 0,
      "on": [
        {
          "events": {
            "type": "touchstart",
            "filter": "event.touches.length===2"
          },
          "update": "pinchDistance(event)"
        },
        {"events": {"signal": "dist2"}, "update": "dist2"}
      ]
    },
    {
      "name": "dist2",
      "value": 0,
      "on": [
        {
          "events": {
            "type": "touchmove",
            "consume": true,
            "filter": "event.touches.length===2"
          },
          "update": "pinchDistance(event)"
        }
      ]
    },
    {
      "name": "xdom",
      "update": "xext",
      "on": [
        {
          "events": {"signal": "delta"},
          "update": "[xcur[0] + span(xcur) * delta[0] / width, xcur[1] + span(xcur) * delta[0] / width]"
        },
        {
          "events": {"signal": "zoom"},
          "update": "[anchor[0] + (xdom[0] - anchor[0]) * zoom, anchor[0] + (xdom[1] - anchor[0]) * zoom]"
        },
        {"events": [{"source": "view", "type": "dblclick"}], "update": "xrange"}
      ]
    },
    {
      "name": "ydom",
      "update": "yext",
      "on": [
        {
          "events": {"signal": "delta"},
          "update": "[ycur[0] + span(ycur) * delta[1] / height, ycur[1] + span(ycur) * delta[1] / height]"
        },
        {
          "events": {"signal": "zoom"},
          "update": "[anchor[1] + (ydom[0] - anchor[1]) * zoom, anchor[1] + (ydom[1] - anchor[1]) * zoom]"
        },
        {"events": [{"source": "view", "type": "dblclick"}], "update": "yrange"}
      ]
    },
    {"name": "size", "update": "clamp(20 / span(xdom), 1, 1000)"},
    {
      "name": "cx",
      "update": "width / 2",
      "on": [
        {
          "events": "[symbol:mousedown, window:mouseup] > window:mousemove",
          "update": " cx==width/2?cx+0.001:width/2"
        }
      ]
    },
    {"name": "cy", "update": "height / 2"},
    {
      "name": "nodeRadiusKey",
      "description": "q=increase size, a=decrease size",
      "value": 8,
      "on": [
        {
          "events": "window:keypress",
          "update": "event.key=='a'&&nodeRadiusKey>1?nodeRadiusKey-1:event.key=='q'?nodeRadiusKey+1:nodeRadiusKey"
        }
      ]
    },
    {
      "name": "nodeRadius",
      "value": 8,
      "bind": {"input": "range", "min": 1, "max": 50, "step": 1},
      "on": [{"events": {"signal": "nodeRadiusKey"}, "update": "nodeRadiusKey"}]
    },
    {
      "name": "nodeCharge",
      "value": -30,
      "bind": {"input": "range", "min": -100, "max": 10, "step": 1}
    },
    {
      "name": "linkDistance",
      "value": 30,
      "bind": {"input": "range", "min": 5, "max": 300, "step": 1}
    },
    {
      "description": "State variable for active node fix status.",
      "name": "fix",
      "value": false,
      "on": [
        {
          "events": "symbol:mouseout[!event.buttons], window:mouseup",
          "update": "false"
        },
        {"events": "symbol:mouseover", "update": "fix || true", "force": true},
        {
          "events": "[symbol:mousedown, window:mouseup] > window:mousemove!",
          "update": "xy()",
          "force": true
        }
      ]
    },
    {
      "description": "Graph node most recently interacted with.",
      "name": "node",
      "value": null,
      "on": [
        {
          "events": "symbol:mouseover",
          "update": "fix === true ? datum.index : node"
        }
      ]
    },
    {
      "name": "nodeHover",
      "value": {"id": null, "connections": []},
      "on": [
        {
          "events": "symbol:mouseover",
          "update": "{'id':datum.index, 'connections':split(datum.sources+','+datum.targets,',')}"
        },
        {"events": "symbol:mouseout", "update": "{'id':null, 'connections':[]}"}
      ]
    },
    {
      "description": "Flag to restart Force simulation upon data changes.",
      "name": "restart",
      "value": false,
      "on": [{"events": {"signal": "fix"}, "update": "fix && fix.length"}]
    }
  ],
  "data": [
    {
      "name": "node-data-raw",
      "url": {
        "%context%": "true",
        "index": "otel-v1-apm-span-*",
        "body": {
          "size": 0,
          "aggs": {
            "services": {
              "terms": {
                "field": "serviceName",
                "size": 10000
              }
            }
          }
        }
      },
      "format": {
        "type": "json",
        "property": "aggregations.services.buckets"
      },
      "transform": [
     {
        "type": "formula",
        "expr": "'http://localhost:5601/goto/25bfc6d157f12043bfc23de965e8bf32'",
        "as": "link"
      },
      {
          "type": "formula",
          "expr": "datum.doc_count",
          "as": "traceCounts"
        },
        {
          "type": "formula",
          "expr": "datum.key",
          "as": "name"
        },
        {
          "type": "formula",
          "expr": "datum.key",
          "as": "group"
        }
      ]
    },
    {
      "name": "link-data-raw",
      "url": {
        "%context%": "true",
        "index": "otel-v1-apm-service-map*",
        "body": {
          "size": 0,
          "aggs": {
            "services": {
              "terms": {
                "size": 10000,
                "field": "serviceName"
              },
              "aggs": {
                "target": {
                  "terms": {
                    "size": 10000,
                    "field": "destination.domain"
                  }
                }
              }
            }
          }
        }
      },
      "format": {
        "property": "aggregations.services.buckets"
      },
      "transform":[
        {
          "type": "flatten",
          "fields": ["target.buckets"],
          "as": ["targetBucket"]
        },
        {
          "type": "formula",
          "expr": "datum.doc_count",
          "as": "value"
        },
        {
          "type": "formula",
          "expr": "datum.key",
          "as": "source"
        },
        {
          "type": "formula",
          "expr": "datum.targetBucket.key",
          "as": "target"
        },
        {
          "type": "lookup",
          "from": "node-data-raw",
          "key": "name",
          "fields": ["source", "target"],
          "as": ["sourceNode", "targetNode"]
        },
        {
          "type": "filter",
          "expr": "datum.sourceNode !== null && datum.targetNode !== null"
        }
      ]
    },
    {"name": "link-data", "source": "link-data-raw"},
    {
      "name": "source-connections",
      "source": "link-data-raw",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["source"],
          "ops": ["values"],
          "fields": ["target"],
          "as": ["connections"]
        },
        {
          "type": "formula",
          "as": "targets",
          "expr": "pluck(datum.connections, 'target')"
        }
      ]
    },
    {
      "name": "target-connections",
      "source": "link-data-raw",
      "transform": [
        {
          "type": "aggregate",
          "groupby": ["target"],
          "ops": ["values"],
          "fields": ["source"],
          "as": ["connections"]
        },
        {
          "type": "formula",
          "as": "sources",
          "expr": "pluck(datum.connections, 'source')"
        }
      ]
    },
    {
      "name": "node-data",
      "source" :"node-data-raw",
      "transform": [
        {
          "type": "lookup",
          "from": "source-connections",
          "key": "source",
          "fields": ["name"],
          "values": ["targets"],
          "as": ["targets"],
          "default": [""]
        },
        {
          "type": "lookup",
          "from": "target-connections",
          "key": "target",
          "fields": ["name"],
          "values": ["sources"],
          "as": ["sources"],
          "default": [""]
        },
        {
          "type": "force",
          "iterations": 300,
          "restart": {"signal": "restart"},
          "signal": "force",
          "forces": [
            {"force": "center", "x": {"signal": "cx"}, "y": {"signal": "cy"}},
            {
              "force": "collide",
              "radius": {"signal": "sqrt(4 * nodeRadius * nodeRadius)"},
              "iterations": 1,
              "strength": 0.7
            },
            {"force": "nbody", "strength": {"signal": "nodeCharge"}},
            {
              "force": "link",
              "links": "link-data-raw",
              "distance": {"signal": "linkDistance"},
              "id": "name"
            }
          ]
        },
        {
          "type": "formula",
          "as": "fx",
          "expr": "fix[0]!=null && node==datum.index ?invert('xscale',fix[0]):null"
        },
        {
          "type": "formula",
          "as": "fy",
          "expr": "fix[1]!=null && node==datum.index ?invert('yscale',fix[1]):null"
        }
      ]
    }
  ],
  "scales": [
    {
      "name": "color",
      "type": "ordinal",
      "domain": {"data": "node-data", "field": "group"},
      "range": [
        "#4682b4",
        "#4666b4",
        "#46b494",
        "#b46746",
        "#b44662",
        "#a44fa3"
      ]
    },
    {
      "name": "xscale",
      "zero": false,
      "domain": {"signal": "xdom"},
      "range": {"signal": "xrange"}
    },
    {
      "name": "yscale",
      "zero": false,
      "domain": {"signal": "ydom"},
      "range": {"signal": "yrange"}
    }
  ],
  "marks": [
    {
      "type": "path",
      "name": "links",
      "from": {"data": "link-data"},
      "interactive": false,
      "encode": {
        "update": {
          "stroke": {
            "signal": "datum.source.index!=nodeHover.id && datum.target.index!=nodeHover.id ? '#929399':merge(hsl(scale('color', datum.source.group)), {l:0.64})"
          },
          "strokeWidth": {
            "signal": "datum.source.index!=nodeHover.id && datum.target.index!=nodeHover.id ? 0.5:2"
          }
        }
      },
      "transform": [
        {
          "type": "linkpath",
          "require": {"signal": "force"},
          "shape": "line",
          "sourceX": {"expr": "scale('xscale', datum.datum.source.x)"},
          "sourceY": {"expr": "scale('yscale', datum.datum.source.y)"},
          "targetX": {"expr": "scale('xscale', datum.datum.target.x)"},
          "targetY": {"expr": "scale('yscale', datum.datum.target.y)"}
        },
        {
          "type": "formula",
          "expr": "atan2(datum.datum.target.y - datum.datum.source.y,datum.datum.source.x - datum.datum.target.x)",
          "as": "angle1"
        },
        {
          "type": "formula",
          "expr": "(datum.angle1>=0?datum.angle1:(2*PI + datum.angle1)) * (360 / (2*PI))",
          "as": "angle2"
        },
        {
          "type": "formula",
          "expr": "(360-datum.angle2)*(PI/180)",
          "as": "angle3"
        },
        {
          "type": "formula",
          "expr": "(cos(datum.angle3)*(nodeRadius+5))+(scale('xscale',datum.datum.target.x))",
          "as": "arrowX"
        },
        {
          "type": "formula",
          "expr": "(sin(datum.angle3)*(nodeRadius+5))+(scale('yscale',datum.datum.target.y))",
          "as": "arrowY"
        }
      ]
    },
    {
      "type": "symbol",
      "name": "arrows",
      "zindex": 1,
      "from": {"data": "links"},
      "encode": {
        "update": {
          "shape": {"value": "triangle"},
          "angle": {"signal": "-datum.angle2-90"},
          "x": {"signal": "datum.arrowX"},
          "y": {"signal": "datum.arrowY"},
          "text": {"signal": "'▲'"},
          "fill": {
            "signal": "datum.datum.source.index!=nodeHover.id && datum.datum.target.index!=nodeHover.id ? '#929399':merge(hsl(scale('color', datum.datum.source.group)), {l:0.64})"
          },
          "size": {"signal": "nodeRadius==1?0:60"}
        }
      }
    },
    {
      "name": "nodes",
      "type": "symbol",
      "zindex": 1,
      "from": {"data": "node-data"},
      "encode": {
        "update": {
            "opacity": {"value": 1},
            "href": {"signal": "datum.link"},
            "fill": {
            "signal": "nodeHover.id===datum.index || indexof(nodeHover.connections, datum.name)>-1 ?scale('color', datum.group):merge(hsl(scale('color', datum.group)), {l:0.64})"
          },
          "stroke": {
            "signal": "nodeHover.id===datum.index || indexof(nodeHover.connections, datum.name)>-1 ?scale('color', datum.group):merge(hsl(scale('color', datum.group)), {l:0.84})"
          },
          "strokeWidth": {"value": 3},
          "strokeOpacity": {"value": 1},
          "size": {"signal": "4 * nodeRadius * nodeRadius"},
          "cursor": {"value": "pointer"},
          "x": {
            "signal": "fix[0]!=null && node===datum.index ?fix[0]:scale('xscale', datum.x)"
          },
          "y": {
            "signal": "fix[1]!=null && node===datum.index ?fix[1]:scale('yscale', datum.y)"
          }
        },
        "hover": {"tooltip": {"signal": "datum.name"}}
      }
    },
    {
      "type": "text",
      "name": "labels",
      "from": {"data": "nodes"},
      "zindex": 2,
      "interactive": false,
      "enter": {},
      "encode": {
        "update": {
          "fill": {"signal": "'white'"},
          "y": {"field": "y"},
          "x": {"field": "x"},
          "text": {"field": "datum.name"},
          "align": {"value": "center"},
          "fontSize": {"value": 10},
          "baseline": {"value": "middle"},
          "limit": {
            "signal": "clamp(sqrt(4 * nodeRadius * nodeRadius)-7,1,1000)"
          },
          "ellipsis": {"value": " "}
        }
      }
    }
  ]
}

Inside a visualization should allow navigation the following dashboard url: http://localhost:5601/goto/25bfc6d157f12043bfc23de965e8bf32

Expected behavior
Clicking any node in the network graph should navigate to the href url

OpenSearch Version
Not relevant

Dashboards Version
Any version (1.* / 2.* )

Plugins
Core

Please list all plugins currently enabled.

  • Issue is within the dashboard vega plugin support

If applicable, add screenshots to help explain your problem.
Screenshot 2024-03-27 at 8 21 18 PM

Host/Environment (please complete the following information):

  • OS: [e.g. iOS]
  • Browser and version [e.g. 22]

Additional context

Fix should be around here

  • Here the uri is not an object but a string so that the condition can also test is the uri is an external url so it can be navigated into i.e test forthis._externalUrl.isInternalUrl(uri))
@YANG-DB YANG-DB added bug Something isn't working untriaged labels Mar 28, 2024
@wbeckler
Copy link

wbeckler commented Apr 2, 2024

Have you tried it with enableExternalUrls: 'vis_type_vega.enableExternalUrls: true'?

@kavilla kavilla self-assigned this Apr 2, 2024
@kavilla kavilla added needs more info Requires more information from poster and removed untriaged labels Apr 2, 2024
@YANG-DB
Copy link
Member Author

YANG-DB commented Apr 2, 2024

@wbeckler , @kavilla the flag vis_type_vega.enableExternalUrls: true doesn’t work - since the url is linking internally it should work event when the above flag is not present ...

here

...
      } else if (!this._enableExternalUrls) {
...

@wbeckler
Copy link

wbeckler commented Apr 2, 2024

I'm thinking that this is a security feature. If one were to "fix" it so that internal URLs work when enableExternalUrls is set to false, you would potentially have a situation where the "isInternalUrl" function becomes a target for inserting malicious external URLs. For example, if someone could find an open redirect that allows an internal URL to redirect to a malicious external URL.

@YANG-DB
Copy link
Member Author

YANG-DB commented Apr 2, 2024

@wbeckler I dont see this as a security issue at all - its just a minor bug fix

@YANG-DB
Copy link
Member Author

YANG-DB commented Apr 15, 2024

@wbeckler @kavilla
Can u plz advice when do u think this bug will be addressed ?

@wbeckler
Copy link

I'm sorry I didn't explain myself well. What I meant was, the existence of this function, while buggy, seems initially intended as a security feature. As in, some admins would want to disable the possibility of malicious URLs in visualizations, and therefore they would limit the visualization to internal URLs. Now, fixing the bug would enable this internal linking to work for users who intended to limit links to internal ones. But what I am wondering, is whether fixing this bug could enable malicious usage due to a failure in the original logic. The malicious use case comes from the fact that the isinternal concept is not sufficient to prevent malicious URLs, since there is the possibility of an open redirect vulnerability. So I was wondering if the solution to fixing internal URLs would create another problem, and therefore the right next move might be to ask how to make a safe internal URL concept work here that is immune to open redirect vulnerabilities.

@ashwin-pc
Copy link
Member

@wbeckler I see where your concern is coming from but I think we should also be more specific about about the potential vulnerability. Users can already add links in their markdown visualization's. These can be external or internal. If a user can already do this for a markdown vis, why are we restricting it for a Vega Vis?

@ashwin-pc
Copy link
Member

As for the potential fix, do you think that is sometign you can contribute @YANG-DB? Right now it is not high up on the priority list for the team but I could help you get the fix merged in (sans any pushback from the maintainers or community :) )

@ashwin-pc ashwin-pc removed the needs more info Requires more information from poster label Apr 15, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

4 participants