microsoft / microsoft/mssql-rs

Harden TVP type name handling in mssql-tds instead of relying on per-binding validation

Open
#347 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement technical-debt
Dominant language
Rust
Stars
53
Forks
14
Avg merge
1d 15h
Merged PRs (30d)
137

Description

### Problem statement

`RpcParameter::format_tvp_sql_name` interpolates a TVP's schema and type name directly into a SQL string that becomes the `@params` declaration passed to `sp_executesql`:

```rust
// mssql-tds/src/message/parameters/rpc_parameters.rs:253-256
fn format_tvp_sql_name(type_name: &TvpTypeName) -> String {
let schema = type_name.schema_name.as_deref().unwrap_or("dbo");
format!("[{schema}].[{}] READONLY", type_name.type_name)
}
```

`mssql-tds` applies no validation on this path. `get_sql_name_impl` returns early for `SqlType::Table` **without** calling `TvpTypeName::validate()`:

```rust
// mssql-tds/src/message/parameters/rpc_parameters.rs:163-169
fn get_sql_name_impl(value: &SqlType) -> TdsResult {
if let SqlType::Table(type_name, _) = value {
return Ok(Self::format_tvp_sql_name(type_name));
}
...
```

and `TvpTypeName::validate()` (`mssql-tds/src/datatypes/sql_tvp.rs:99-106`) only checks that the type name is non-empty in any case. A `]` in either part terminates the bracket-quoted identifier and lets arbitrary text into the declaration.

Today the only thing preventing that is a guard in a *consumer*. `parse_tvp_type_name` in `mssql-py-core` rejects `]` in both parts, with a TODO pointing back here:

```rust
// mssql-py-core/src/async_parameters.rs:211-221
// TODO(mssql-tds): Escape closing brackets when formatting TVP parameter
// declarations. Until then, reject names that cannot be represented safely.
```

That guard is correct and sufficient for the Python binding — `]` is the only character that can escape `[...]`.

**This is not a live vulnerability.** `mssql-py-core` is currently the only binding that exposes TVPs, and it is guarded. The problem is that the invariant is enforced one layer above where it is needed, so it holds only by convention. Any future consumer — `mssql-js`, `mssql-odbc`, `mssql-tds-cli`, or a direct Rust caller — gets a SQL injection primitive by default if the type name is ever attacker-influenced (TVP type chosen from a config value, a tenant name, or an API parameter).

### Proposed solution

Move the invariant into `mssql-tds`, where the string is actually built. Either option works:

**Option 1 — escape (preferred, cannot break otherwise-valid names):**

```rust
/// `]]` is T-SQL's escape for a literal `]` inside a delimited identifier,
/// so names that legitimately contain `]` still round-trip.
fn quote_bracket_identifier(name: &str) -> String {
format!("[{}]", name.replace(']', "]]"))
}

fn format_tvp_sql_name(type_name: &TvpTypeName) -> String {
let schema = type_name.schema_name.as_deref().unwrap_or("dbo");
format!(
"{}.{} READONLY",
quote_bracket_identifier(schema),
quote_bracket_identifier(&type_name.type_name)
)
}
```

**Option 2 — reject:** extend `TvpTypeName::validate()` to reject `]` and call it from `get_sql_name_impl` before formatting. Simpler, but it makes an otherwise-legal identifier unusable and surfaces the failure at serialization time, far from the caller.

If Option 1 lands, `mssql-py-core`'s `]` rejection and its TODO can both be deleted, and future bindings inherit the protection automatically.

**Suggested tests** (`mssql-tds/tests/test_tvp.rs` already covers TVPs):

```rust
#[test]
fn test_tvp_sql_name_escapes_closing_bracket() {
let value = SqlType::Table(
TvpTypeName::new(Some("dbo".to_string()), "My]Type".to_string()),
None,
);
assert_eq!(
RpcParameter::get_sql_name(&value).unwrap(),
"[dbo].[My]]Type] READONLY"
);
}

#[test]
fn test_tvp_sql_name_escapes_closing_bracket_in_schema() {
let value = SqlType::Table(
TvpTypeName::new(Some("s]s".to_string()), "MyType".to_string()),
None,
);
assert_eq!(
RpcParameter::get_sql_name(&value).unwrap(),
"[s]]s].[MyType] READONLY"
);
}

/// A name crafted to close the identifier and append a second declaration must
/// stay inside one identifier rather than producing an extra parameter.
#[test]
fn test_tvp_sql_name_cannot_inject_second_declaration() {
let value = SqlType::Table(
TvpTypeName::new(None, "T] READONLY, @x int OUTPUT --".to_string()),
None,
);
assert_eq!(
RpcParameter::get_sql_name(&value).unwrap(),
"[dbo].[T]] READONLY, @x int OUTPUT --] READONLY"
);
}
```

`get_sql_name` is already `pub` under `#[cfg(fuzzing)]` (`rpc_parameters.rs:153-156`), so this is also a natural target for the existing fuzzing setup.

### Affected crate

mssql-tds

### Alternatives considered

- **Leave it as-is and require every binding to validate.** This is the current state, and it already needed a TODO to remember. It will silently regress the first time a new binding adds TVP support.
- **Validate in `TvpTypeName::new`.** Attractive, but `new` is infallible today and making it fallible is a breaking change to a public type. Format-time escaping keeps the constructor signature intact.

### Additional context

Found while reviewing #340, which adds TVP support to `mssql-py-core`.

Relevant code:

| Location | Note |
|---|---|
| `mssql-tds/src/message/parameters/rpc_parameters.rs:163-169` | `get_sql_name_impl` returns before any validation for `SqlType::Table` |
| `mssql-tds/src/message/parameters/rpc_parameters.rs:253-256` | `format_tvp_sql_name`, the interpolation site |
| `mssql-tds/src/datatypes/sql_tvp.rs:99-106` | `TvpTypeName::validate`, emptiness check only |
| `mssql-py-core/src/async_parameters.rs:211-221` | Consumer-side `]` rejection and the TODO that motivated this issue |

Scope note: TVP *values* and the wire-level type name are unaffected. `write_tvp_type_name` (`sql_tvp.rs:224-231`) writes length-prefixed `B_VARCHAR`s, so only the `sp_executesql` declaration text is at risk.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with mssql-tds/src/message/parameters/rpc_parameters.rs, especially get_sql_name_impl and format_tvp_sql_name, then review the existing TVP coverage in mssql-tds/tests/test_tvp.rs. Verify that closing brackets in both schema and type names remain within quoted identifiers, including the injection-shaped case, and remove the now-unneeded guard and TODO in mssql-py-core/src/async_parameters.rs if escaping is used.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend, security
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.