diff --git a/.secrets.baseline b/.secrets.baseline index 89bca4ba49..94dbcc229e 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1251,10 +1251,10 @@ "filename": "src/timestream-for-influxdb-mcp-server/tests/test_server.py", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 619, + "line_number": 620, "is_secret": false } ] }, - "generated_at": "2026-07-16T18:42:37Z" + "generated_at": "2026-08-10T16:13:08Z" } diff --git a/src/timestream-for-influxdb-mcp-server/README.md b/src/timestream-for-influxdb-mcp-server/README.md index 5225d64aaf..73e4d5cb01 100644 --- a/src/timestream-for-influxdb-mcp-server/README.md +++ b/src/timestream-for-influxdb-mcp-server/README.md @@ -40,6 +40,7 @@ You can modify the settings of your MCP client to run your local server (e.g. fo "INFLUXDB_URL": "https://your-influxdb-endpoint:8086", "INFLUXDB_TOKEN": "your-influxdb-token", "INFLUXDB_ORG": "your-influxdb-org", + "INFLUXDB_WRITE_MODE": "false", "FASTMCP_LOG_LEVEL": "ERROR" }, "disabled": false, @@ -73,6 +74,7 @@ For Windows users, the MCP server configuration format is slightly different: "INFLUXDB_URL": "https://your-influxdb-endpoint:8086", "INFLUXDB_TOKEN": "your-influxdb-token", "INFLUXDB_ORG": "your-influxdb-org", + "INFLUXDB_WRITE_MODE": "false", "FASTMCP_LOG_LEVEL": "ERROR" } } @@ -152,3 +154,32 @@ The Timestream for InfluxDB MCP server provides the following tools: ##### Organization Management - `InfluxDBListOrgs`: List all organizations in InfluxDB - `InfluxDBCreateOrg`: Create a new organization in InfluxDB + +## Configuration + +### Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `AWS_PROFILE` | No | — | AWS profile for control-plane operations | +| `AWS_REGION` | No | `us-east-1` | AWS region | +| `INFLUXDB_URL` | No | — | InfluxDB v2 endpoint URL (e.g. `https://host:8086`) | +| `INFLUXDB_TOKEN` | No | — | InfluxDB v2 authentication token | +| `INFLUXDB_ORG` | No | — | InfluxDB v2 organization name | +| `INFLUXDB_ALLOWED_URLS` | No | — | Comma-separated list of additional approved InfluxDB URLs | +| `INFLUXDB_WRITE_MODE` | No | `false` | Enable write-producing Flux operations in queries (see below) | +| `FASTMCP_LOG_LEVEL` | No | — | Logging level (`ERROR`, `WARNING`, `INFO`, `DEBUG`) | + +### Write Mode + +By default, the `InfluxDBQuery` tool rejects Flux queries that contain write-producing operations such as `to()`, `experimental.to()`, and `wideTo()`. This ensures that the query tool behaves as read-only, even when the configured InfluxDB token has write permissions. + +To enable write-producing Flux operations through the query tool, set: + +``` +INFLUXDB_WRITE_MODE=true +``` + +This setting is operator-controlled and cannot be overridden by tool callers. + +> **Best practice:** For deployments that need both read and write capabilities, configure a **read-only InfluxDB token** for general use and enable the explicit write tools (`InfluxDBWritePoints`, `InfluxDBWriteLP`) with `tool_write_mode=True` only when needed. This provides the strongest data integrity guarantee regardless of the MCP server's write-mode setting. diff --git a/src/timestream-for-influxdb-mcp-server/awslabs/timestream_for_influxdb_mcp_server/server.py b/src/timestream-for-influxdb-mcp-server/awslabs/timestream_for_influxdb_mcp_server/server.py index b9cc0f2dc0..da261becd8 100644 --- a/src/timestream-for-influxdb-mcp-server/awslabs/timestream_for_influxdb_mcp_server/server.py +++ b/src/timestream-for-influxdb-mcp-server/awslabs/timestream_for_influxdb_mcp_server/server.py @@ -17,6 +17,7 @@ import boto3 import os +import re from awslabs.timestream_for_influxdb_mcp_server import __version__ from botocore.config import Config from influxdb_client.client.influxdb_client import InfluxDBClient @@ -40,6 +41,7 @@ INFLUXDB_URL = os.environ.get('INFLUXDB_URL') INFLUXDB_ORG = os.environ.get('INFLUXDB_ORG') INFLUXDB_ALLOWED_URLS = os.environ.get('INFLUXDB_ALLOWED_URLS', '') +INFLUXDB_WRITE_MODE = os.environ.get('INFLUXDB_WRITE_MODE', 'false').lower() == 'true' # Define Field parameters as global variables to avoid duplication # Common fields @@ -437,6 +439,63 @@ def get_influxdb_client(url, token, org=None, timeout=10000, verify_ssl: bool = ) +def validate_flux_query(query: str) -> None: + """Validate that a Flux query does not contain write-producing operations. + + When INFLUXDB_WRITE_MODE is disabled (default), this function rejects Flux + queries that contain functions capable of writing data to InfluxDB. This + provides defense-in-depth protection against data modification through + the query interface. + + The following write-producing Flux functions are blocked: + - to() — standard write function (influxdata/influxdb/to) + - experimental.to() — experimental write function + - wideTo() / influxdb.wideTo() — writes wide/pivoted data + + Args: + query: The Flux query string to validate. + + Raises: + ValueError: If the query contains write-producing operations and + INFLUXDB_WRITE_MODE is not enabled. + """ + if INFLUXDB_WRITE_MODE: + return + + # Strip single-line comments (// ...) to avoid false positives from commented-out code. + # Preserve line structure so that multi-line patterns still work. + stripped = re.sub(r'//[^\n]*', '', query) + + # Patterns to detect write-producing Flux functions. + # These cover: + # - Pipe-forward to `to(` — e.g. `|> to(bucket: "x")` + # - Standalone or qualified calls — e.g. `to(bucket: "x")`, + # `experimental.to(`, `influxdb.wideTo(` + # - Aliased imports — e.g. `import ex "experimental"` then `ex.to(` + # The word-boundary and parenthesis requirements minimize false positives + # from identifiers like `toString` or field names containing "to". + write_patterns = [ + # Pipe-forward into to(): |> to( + r'\|>\s*to\s*\(', + # Qualified calls: experimental.to( or .to( preceded by word char + dot + r'\w+\.to\s*\(', + # wideTo as standalone or qualified: wideTo( or .wideTo( + r'(?:\w+\.)?wideTo\s*\(', + # Standalone to() at statement level — preceded by start-of-line or semicolon + # but NOT preceded by a dot (which would be a method on something else). + r'(?:^|;\s*)to\s*\(', + ] + + for pattern in write_patterns: + if re.search(pattern, stripped, re.MULTILINE): + raise ValueError( + 'Query contains a write-producing Flux operation (to(), experimental.to(), ' + 'or wideTo()) which is not allowed when INFLUXDB_WRITE_MODE is not enabled. ' + 'Set the INFLUXDB_WRITE_MODE=true environment variable to allow write ' + 'operations through Flux queries.' + ) + + @mcp.tool( name='CreateDbCluster', description='Create a new Timestream for InfluxDB database cluster.' ) @@ -1365,6 +1424,9 @@ async def influxdb_query( Returns: Query results in the specified format. """ + # Validate query does not contain write-producing operations in read-only mode + validate_flux_query(query) + resolved_url, resolved_token, resolved_org = resolve_influxdb_config(url, token, org) try: diff --git a/src/timestream-for-influxdb-mcp-server/tests/test_server.py b/src/timestream-for-influxdb-mcp-server/tests/test_server.py index be52ef772f..6c989bfe71 100644 --- a/src/timestream-for-influxdb-mcp-server/tests/test_server.py +++ b/src/timestream-for-influxdb-mcp-server/tests/test_server.py @@ -45,6 +45,7 @@ untag_resource, update_db_cluster, update_db_instance, + validate_flux_query, ) from unittest.mock import MagicMock, patch @@ -2695,3 +2696,334 @@ async def test_influxdb_create_bucket_org_not_found(self, mock_get_client): assert result['status'] == 'error' assert 'not found' in result['message'] + + +@patch( + 'awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_ALLOWED_URLS', + 'https://influxdb-example.aws:8086', +) +class TestFluxQueryWriteGuard: + """Tests proving write-shaped Flux is rejected in read-only mode (default). + + These tests validate the defense-in-depth fix for the security vulnerability + where InfluxDBQuery could execute Flux write primitives (to(), experimental.to(), + influxdb.wideTo()) without a write-mode guard. + """ + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_rejects_pipe_forward_to(self, mock_get_client): + """Test that pipe-forward to() is rejected in read-only mode.""" + query = 'from(bucket: "source") |> range(start: -1h) |> to(bucket: "dest")' + + with pytest.raises(ValueError) as excinfo: + await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert 'write-producing Flux operation' in str(excinfo.value) + mock_get_client.assert_not_called() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_rejects_experimental_to(self, mock_get_client): + """Test that experimental.to() is rejected in read-only mode.""" + query = """import "experimental" +from(bucket: "source") + |> range(start: -1h) + |> experimental.to(bucket: "dest", org: "my-org")""" + + with pytest.raises(ValueError) as excinfo: + await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert 'write-producing Flux operation' in str(excinfo.value) + mock_get_client.assert_not_called() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_rejects_wideTo(self, mock_get_client): + """Test that wideTo() is rejected in read-only mode.""" + query = """import "influxdata/influxdb" +from(bucket: "source") + |> range(start: -1h) + |> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value") + |> wideTo(bucket: "dest")""" + + with pytest.raises(ValueError) as excinfo: + await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert 'write-producing Flux operation' in str(excinfo.value) + mock_get_client.assert_not_called() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_rejects_influxdb_wideTo(self, mock_get_client): + """Test that influxdb.wideTo() is rejected in read-only mode.""" + query = """import "influxdata/influxdb" +from(bucket: "source") + |> range(start: -1h) + |> influxdb.wideTo(bucket: "dest", org: "my-org")""" + + with pytest.raises(ValueError) as excinfo: + await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert 'write-producing Flux operation' in str(excinfo.value) + mock_get_client.assert_not_called() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_rejects_aliased_experimental_to(self, mock_get_client): + """Test that aliased experimental import with to() is rejected.""" + query = """import ex "experimental" +from(bucket: "source") + |> range(start: -1h) + |> ex.to(bucket: "dest")""" + + with pytest.raises(ValueError) as excinfo: + await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert 'write-producing Flux operation' in str(excinfo.value) + mock_get_client.assert_not_called() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_rejects_to_with_extra_whitespace(self, mock_get_client): + """Test that to() with extra whitespace is still caught.""" + query = 'from(bucket: "source") |> to (bucket: "dest")' + + with pytest.raises(ValueError) as excinfo: + await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert 'write-producing Flux operation' in str(excinfo.value) + mock_get_client.assert_not_called() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_rejects_standalone_to_at_line_start(self, mock_get_client): + """Test that standalone to() at line start is rejected.""" + query = """from(bucket: "source") |> range(start: -1h) +to(bucket: "dest", org: "my-org")""" + + with pytest.raises(ValueError) as excinfo: + await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert 'write-producing Flux operation' in str(excinfo.value) + mock_get_client.assert_not_called() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', True) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_allows_to_when_write_mode_enabled(self, mock_get_client): + """Test that to() is allowed when INFLUXDB_WRITE_MODE is True.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_query_api = MagicMock() + mock_client.query_api.return_value = mock_query_api + mock_query_api.query.return_value = [] + + query = 'from(bucket: "source") |> range(start: -1h) |> to(bucket: "dest")' + + result = await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert result['status'] == 'success' + mock_get_client.assert_called_once() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_allows_normal_read_query(self, mock_get_client): + """Test that normal read queries are not blocked.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_query_api = MagicMock() + mock_client.query_api.return_value = mock_query_api + mock_query_api.query.return_value = [] + + query = 'from(bucket: "my-bucket") |> range(start: -1h) |> filter(fn: (r) => r._measurement == "cpu")' + + result = await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert result['status'] == 'success' + mock_get_client.assert_called_once() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_allows_toString_function(self, mock_get_client): + """Test that toString() is not falsely blocked (no false positive).""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_query_api = MagicMock() + mock_client.query_api.return_value = mock_query_api + mock_query_api.query.return_value = [] + + query = 'from(bucket: "b") |> range(start: -1h) |> map(fn: (r) => ({r with _value: string(v: r._value)}))' + + result = await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert result['status'] == 'success' + mock_get_client.assert_called_once() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_ignores_commented_out_to(self, mock_get_client): + """Test that commented-out to() is not blocked.""" + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_query_api = MagicMock() + mock_client.query_api.return_value = mock_query_api + mock_query_api.query.return_value = [] + + query = """from(bucket: "b") |> range(start: -1h) +// |> to(bucket: "dest") +|> yield()""" + + result = await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert result['status'] == 'success' + mock_get_client.assert_called_once() + + @pytest.mark.asyncio + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + @patch('awslabs.timestream_for_influxdb_mcp_server.server.get_influxdb_client') + async def test_rejects_to_in_multiline_query(self, mock_get_client): + """Test that to() in a complex multi-line query is caught.""" + query = """import "influxdata/influxdb" + +data = from(bucket: "source") + |> range(start: -1h) + |> filter(fn: (r) => r._measurement == "cpu") + |> aggregateWindow(every: 5m, fn: mean) + +data + |> to( + bucket: "destination", + org: "my-org" + )""" + + with pytest.raises(ValueError) as excinfo: + await influxdb_query( + url='https://influxdb-example.aws:8086', + token='test-token', + org='test-org', + query=query, + ) + + assert 'write-producing Flux operation' in str(excinfo.value) + mock_get_client.assert_not_called() + + +class TestValidateFluxQueryUnit: + """Unit tests for validate_flux_query function directly.""" + + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + def test_rejects_simple_to(self): + """Test simple pipe-forward to().""" + with pytest.raises(ValueError): + validate_flux_query('from(bucket: "b") |> to(bucket: "x")') + + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + def test_rejects_experimental_to(self): + """Test experimental.to().""" + with pytest.raises(ValueError): + validate_flux_query('data |> experimental.to(bucket: "x")') + + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + def test_rejects_wideTo(self): + """Test wideTo().""" + with pytest.raises(ValueError): + validate_flux_query('data |> wideTo(bucket: "x")') + + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + def test_rejects_influxdb_wideTo(self): + """Test influxdb.wideTo().""" + with pytest.raises(ValueError): + validate_flux_query('data |> influxdb.wideTo(bucket: "x")') + + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + def test_allows_safe_query(self): + """Test that a safe query passes validation.""" + # Should not raise + validate_flux_query('from(bucket: "b") |> range(start: -1h) |> yield()') + + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', True) + def test_allows_write_when_enabled(self): + """Test that write is allowed when INFLUXDB_WRITE_MODE is True.""" + # Should not raise even with to() + validate_flux_query('from(bucket: "b") |> to(bucket: "x")') + + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + def test_ignores_single_line_comment(self): + """Test that single-line comments with to() don't trigger rejection.""" + # Should not raise — the to() is in a comment + validate_flux_query( + 'from(bucket: "b") |> range(start: -1h)\n// |> to(bucket: "x")\n|> yield()' + ) + + @patch('awslabs.timestream_for_influxdb_mcp_server.server.INFLUXDB_WRITE_MODE', False) + def test_rejects_to_after_comment(self): + """Test that to() on a line after a comment is still caught.""" + query = '// This is a comment\nfrom(bucket: "b") |> to(bucket: "x")' + with pytest.raises(ValueError): + validate_flux_query(query)