Repository: reata/sqllineage

Issue:
Restricting folder and files user can access from frontend
https://reata.github.io/sqllineage/?f=/etc/passwd should not display the whole /etc tree. Contents are not displayed, however :)

Looks a bit like a security issue

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/reata/sqllineage/pull/439?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#439](https://app.codecov.io/gh/reata/sqllineage/pull/439?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (911c36c) into [master](https://app.codecov.io/gh/reata/sqllineage/commit/a896a5ab20ce3540afe4e035310fef5b68b8f788?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (a896a5a) will **decrease** coverage by `0.16%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master     #439      +/-   ##
==========================================
- Coverage   99.53%   99.38%   -0.16%     
==========================================
  Files          37       37              
  Lines        1953     1959       +6     
==========================================
+ Hits         1944     1947       +3     
- Misses          9       12       +3     
```


| [Files Changed](https://app.codecov.io/gh/reata/sqllineage/pull/439?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [sqllineage/drawing.py](https://app.codecov.io/gh/reata/sqllineage/pull/439?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3FsbGluZWFnZS9kcmF3aW5nLnB5) | `100.00% <100.00%> (ø)` | |

... and [2 files with indirect coverage changes](https://app.codecov.io/gh/reata/sqllineage/pull/439/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)



Patch:
diff --git a/sqllineage/drawing.py b/sqllineage/drawing.py
index 0b2599b..4c6bf1a 100644
--- a/sqllineage/drawing.py
+++ b/sqllineage/drawing.py
@@ -34,6 +34,7 @@ logger = logging.getLogger(__name__)
 class SQLLineageApp:
     def __init__(self) -> None:
         self.routes: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
+        self.root_path = Path(DATA_FOLDER)
 
     def route(self, path: str):
         def wrapper(handler):
@@ -74,6 +75,11 @@ class SQLLineageApp:
                     request_body_size = int(environ["CONTENT_LENGTH"])
                     request_body = environ["wsgi.input"].read(request_body_size)
                     payload = json.loads(request_body)
+                    for param in ["d", "f"]:
+                        if param in payload and not str(
+                            Path(payload[param]).absolute()
+                        ).startswith(str(Path(self.root_path).absolute())):
+                            return self.handle_403(start_response)
                     data = self.routes[path_info](payload)
                     return self.handle_200_json(start_response, data)
                 else:
@@ -117,6 +123,12 @@ class SQLLineageApp:
             start_response, HTTPStatus.BAD_REQUEST, message
         )
 
+    def handle_403(self, start_response) -> List[bytes]:
+        message = "File Not Allowed For Accessing"
+        return self.handle_client_error_response(
+            start_response, HTTPStatus.FORBIDDEN, message
+        )
+
     def handle_404(self, start_response) -> List[bytes]:
         message = "File Not Found"
         return self.handle_client_error_response(
@@ -199,6 +211,8 @@ def draw_lineage_graph(**kwargs) -> None:
     port = kwargs.pop("port", DEFAULT_PORT)
     querystring = urlencode({k: v for k, v in kwargs.items() if v})
     path = f"/?{querystring}" if querystring else "/"
+    if "f" in kwargs:
+        app.root_path = Path(kwargs["f"]).parent
     with make_server(host, port, app) as httpd:
         print(f" * SQLLineage Running on http://{host}:{port}{path}")
         httpd.serve_forever()


Test patch:
diff --git a/tests/core/test_drawing.py b/tests/core/test_drawing.py
index 9be3d3a..103a206 100644
--- a/tests/core/test_drawing.py
+++ b/tests/core/test_drawing.py
@@ -4,6 +4,7 @@ from collections import namedtuple
 from http import HTTPStatus
 from io import StringIO
 
+from sqllineage import DATA_FOLDER
 from sqllineage.drawing import app
 
 
@@ -37,9 +38,11 @@ def test_handler():
     assert container.status.startswith(str(HTTPStatus.OK.value))
     mock_request("POST", "/script", {"e": "SELECT * FROM dual", "p": 5000})
     assert container.status.startswith(str(HTTPStatus.OK.value))
-    mock_request("POST", "/directory", {"f": __file__})
+    mock_request(
+        "POST", "/directory", {"f": os.path.join(DATA_FOLDER, "tpcds/query01.sql")}
+    )
     assert container.status.startswith(str(HTTPStatus.OK.value))
-    mock_request("POST", "/directory", {"d": os.path.dirname(__file__)})
+    mock_request("POST", "/directory", {"d": os.path.join(DATA_FOLDER, "tpcds/")})
     assert container.status.startswith(str(HTTPStatus.OK.value))
     mock_request("POST", "/directory", {})
     assert container.status.startswith(str(HTTPStatus.OK.value))
@@ -48,12 +51,19 @@ def test_handler():
     # 400
     mock_request("POST", "/lineage", {"e": "SELECT * FROM where foo='bar'"})
     assert container.status.startswith(str(HTTPStatus.BAD_REQUEST.value))
+    # 403
+    mock_request("POST", "/directory", {"f": "/etc/passwd"})
+    assert container.status.startswith(str(HTTPStatus.FORBIDDEN.value))
+    mock_request("POST", "/directory", {"d": "/"})
+    assert container.status.startswith(str(HTTPStatus.FORBIDDEN.value))
     # 404
     mock_request("GET", "/non-exist-resource")
     assert container.status.startswith(str(HTTPStatus.NOT_FOUND.value))
     mock_request("GET", "/static")
     assert container.status.startswith(str(HTTPStatus.NOT_FOUND.value))
-    mock_request("POST", "/script", {"f": "non-exist-file"})
+    mock_request(
+        "POST", "/script", {"f": os.path.join(DATA_FOLDER, "tpcds/query100.sql")}
+    )
     assert container.status.startswith(str(HTTPStatus.NOT_FOUND.value))
     mock_request("POST", "/non-exist-resource", {"e": "SELECT * FROM where foo='bar'"})
     assert container.status.startswith(str(HTTPStatus.NOT_FOUND.value))