Skip to content

Commit e40eca1

Browse files
VAULT-39294: Deprecate recover_snapshot_id query param and use a header instead (#8834) (#9042)
* deprecate snapshot query params, use a header instead * keep read query param, but deprecate recover one * fix test * remove list change * add changelog * rename header, allow request method * update changelog Co-authored-by: miagilepner <mia.epner@hashicorp.com>
1 parent c9605c7 commit e40eca1

File tree

10 files changed

+145
-32
lines changed

10 files changed

+145
-32
lines changed

api/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ const (
7070
// SSRF protection.
7171
RequestHeaderName = "X-Vault-Request"
7272

73+
SnapshotHeaderName = "X-Vault-Recover-Snapshot-Id"
74+
RecoverSourcePathHeaderName = "X-Vault-Recover-Source-Path"
75+
7376
TLSErrorString = "This error usually means that the server is running with TLS disabled\n" +
7477
"but the client is configured to use TLS. Please either enable TLS\n" +
7578
"on the server or run the client with -address set to an address\n" +

api/logical.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,9 @@ func (c *Logical) Recover(ctx context.Context, path string, snapshotID string) (
309309
func (c *Logical) RecoverFromPath(ctx context.Context, newPath string, snapshotID string, originalPath string) (*Secret, error) {
310310
r := c.c.NewRequest(http.MethodPut, "/v1/"+newPath)
311311
r.Params.Set("recover_snapshot_id", snapshotID)
312+
r.Headers.Set(SnapshotHeaderName, snapshotID)
312313
if originalPath != "" && originalPath != newPath {
313-
r.Params.Set("recover_source_path", url.QueryEscape(originalPath))
314+
r.Headers.Set(RecoverSourcePathHeaderName, originalPath)
314315
}
315316
return c.write(ctx, originalPath, r)
316317
}

changelog/_8834.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:change
2+
Secrets Recovery (enterprise): Deprecate the `recover_snapshot_id` query parameter to pass the snapshot ID for recover operations, in favor of a `X-Vault-Recover-Snapshot-Id` header. Vault will still accept the query parameter for backward compatibility. Also support setting the HTTP method to `RECOVER` for recover operations, in addition to `POST` and `PUT`.
3+
```

http/handler.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,15 @@ const (
8484
VaultSnapshotReadParam = "read_snapshot_id"
8585
// VaultSnapshotRecoverParam is the query parameter sent when Vault should
8686
// recover the data from a loaded snapshot
87+
// Deprecated: use VaultSnapshotRecoverHeader
8788
VaultSnapshotRecoverParam = "recover_snapshot_id"
88-
// VaultRecoverSourcePathParam contains an optional source path
89+
// VaultRecoverSourcePathHeader contains an optional source path
8990
// to read the data from when performing a recover operation
90-
VaultRecoverSourcePathParam = consts.RecoverSourcePathParam
91+
VaultRecoverSourcePathHeader = consts.RecoverSourcePathHeader
92+
// VaultSnapshotRecoverHeader holds the snapshot ID to use for a read, list, or
93+
// recover from snapshot operation. This replaces the use of query parameters
94+
// to pass the snapshot ID
95+
VaultSnapshotRecoverHeader = "X-Vault-Recover-Snapshot-Id"
9196
// CustomMaxJSONDepth specifies the maximum nesting depth of a JSON object.
9297
// This limit is designed to prevent stack exhaustion attacks from deeply
9398
// nested JSON payloads, which could otherwise lead to a denial-of-service
@@ -1505,7 +1510,7 @@ func requiresSnapshot(r *http.Request) bool {
15051510
case http.MethodGet, "LIST":
15061511
return query.Has(VaultSnapshotReadParam)
15071512
case http.MethodPut, http.MethodPost:
1508-
return query.Has(VaultSnapshotRecoverParam)
1513+
return query.Has(VaultSnapshotRecoverParam) || r.Header.Get(VaultSnapshotRecoverParam) != ""
15091514
}
15101515
return false
15111516
}

http/logical.go

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"mime"
1313
"net"
1414
"net/http"
15-
"net/url"
1615
"strconv"
1716
"strings"
1817
"time"
@@ -98,8 +97,12 @@ func buildLogicalRequestNoAuth(perfStandby bool, ra *vault.RouterAccess, w http.
9897
responseWriter = w
9998
}
10099

101-
case "POST", "PUT":
102-
op = logical.UpdateOperation
100+
case "POST", "PUT", "RECOVER":
101+
if r.Method == "RECOVER" {
102+
op = logical.RecoverOperation
103+
} else {
104+
op = logical.UpdateOperation
105+
}
103106

104107
// Buffer the request body in order to allow us to peek at the beginning
105108
// without consuming it. This approach involves no copying.
@@ -219,25 +222,28 @@ func buildLogicalRequestNoAuth(perfStandby bool, ra *vault.RouterAccess, w http.
219222
data = nil
220223
}
221224
}
222-
case logical.UpdateOperation:
223-
queryVals := r.URL.Query()
224-
if queryVals.Has(VaultSnapshotRecoverParam) {
225-
snapshotID := queryVals.Get(VaultSnapshotRecoverParam)
226-
if snapshotID != "" {
227-
requiredSnapshotID = snapshotID
228-
op = logical.RecoverOperation
225+
case logical.UpdateOperation, logical.RecoverOperation:
226+
snapshotHeaderID := r.Header.Get(VaultSnapshotRecoverHeader)
227+
if snapshotHeaderID != "" {
228+
requiredSnapshotID = snapshotHeaderID
229+
} else {
230+
queryVals := r.URL.Query()
231+
if queryVals.Has(VaultSnapshotRecoverParam) {
232+
snapshotID := queryVals.Get(VaultSnapshotRecoverParam)
233+
if snapshotID != "" {
234+
requiredSnapshotID = snapshotID
235+
}
229236
}
230237
}
231-
if op == logical.RecoverOperation {
232-
if queryVals.Has(VaultRecoverSourcePathParam) {
233-
sourcePath := queryVals.Get(VaultRecoverSourcePathParam)
234-
if sourcePath != "" {
235-
unescapedPath, err := url.QueryUnescape(sourcePath)
236-
if err != nil {
237-
return nil, nil, http.StatusBadRequest, fmt.Errorf("failed to unescape %s query parameter: %w", VaultRecoverSourcePathParam, err)
238-
}
239-
recoverSourcePath = trimPath(ns, unescapedPath)
240-
}
238+
if requiredSnapshotID == "" && op == logical.RecoverOperation {
239+
return nil, nil, http.StatusBadRequest, fmt.Errorf("missing required snapshot ID")
240+
}
241+
242+
if requiredSnapshotID != "" {
243+
op = logical.RecoverOperation
244+
sourcePath := r.Header.Get(VaultRecoverSourcePathHeader)
245+
if sourcePath != "" {
246+
recoverSourcePath = trimPath(ns, sourcePath)
241247
}
242248
}
243249
}

http/logical_test.go

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1071,9 +1071,11 @@ func TestLogical_SnapshotParams(t *testing.T) {
10711071
method string
10721072
url string
10731073
body []byte
1074+
headers map[string]string
10741075
wantData map[string]interface{}
10751076
wantOperation logical.Operation
10761077
wantRequiresSnapshotID string
1078+
wantError bool
10771079
}{
10781080
{
10791081
name: "normal get",
@@ -1109,6 +1111,31 @@ func TestLogical_SnapshotParams(t *testing.T) {
11091111
wantOperation: logical.ReadOperation,
11101112
wantRequiresSnapshotID: "1234",
11111113
},
1114+
{
1115+
name: "snapshot list header",
1116+
method: http.MethodGet,
1117+
url: "https://example.com?list=true",
1118+
body: nil,
1119+
headers: map[string]string{
1120+
VaultSnapshotRecoverHeader: "1234",
1121+
},
1122+
wantData: nil,
1123+
wantOperation: logical.ListOperation,
1124+
wantRequiresSnapshotID: "",
1125+
},
1126+
{
1127+
name: "snapshot read header",
1128+
method: http.MethodGet,
1129+
url: "https://example.com",
1130+
headers: map[string]string{
1131+
VaultSnapshotRecoverHeader: "1234",
1132+
},
1133+
body: nil,
1134+
wantData: nil,
1135+
wantOperation: logical.ReadOperation,
1136+
wantRequiresSnapshotID: "",
1137+
},
1138+
11121139
{
11131140
name: "normal update",
11141141
method: http.MethodPost,
@@ -1130,15 +1157,59 @@ func TestLogical_SnapshotParams(t *testing.T) {
11301157
wantOperation: logical.RecoverOperation,
11311158
wantRequiresSnapshotID: "1234",
11321159
},
1160+
{
1161+
name: "snapshot update header",
1162+
method: http.MethodPost,
1163+
url: "https://example.com",
1164+
body: []byte(`{"other_data":"abcd"}`),
1165+
headers: map[string]string{
1166+
VaultSnapshotRecoverHeader: "1234",
1167+
},
1168+
wantData: map[string]interface{}{
1169+
"other_data": "abcd",
1170+
},
1171+
wantOperation: logical.RecoverOperation,
1172+
wantRequiresSnapshotID: "1234",
1173+
},
1174+
{
1175+
name: "recover operation no snapshot",
1176+
method: "RECOVER",
1177+
url: "https://example.com",
1178+
body: []byte(`{"other_data":"abcd"}`),
1179+
headers: map[string]string{
1180+
"other_header": "value",
1181+
},
1182+
wantError: true,
1183+
},
1184+
{
1185+
name: "recover operation with snapshot",
1186+
method: "RECOVER",
1187+
url: "https://example.com",
1188+
body: []byte(`{"other_data":"abcd"}`),
1189+
headers: map[string]string{
1190+
VaultSnapshotRecoverHeader: "1234",
1191+
},
1192+
wantOperation: logical.RecoverOperation,
1193+
wantData: map[string]interface{}{
1194+
"other_data": "abcd",
1195+
},
1196+
wantRequiresSnapshotID: "1234",
1197+
},
11331198
}
11341199

11351200
for _, tc := range testCases {
11361201
t.Run(tc.name, func(t *testing.T) {
11371202
req, _ := http.NewRequest(tc.method, tc.url, bytes.NewReader(tc.body))
11381203
req = req.WithContext(namespace.RootContext(nil))
11391204
req.Header.Add(consts.AuthHeaderName, rootToken)
1140-
1205+
for k, v := range tc.headers {
1206+
req.Header.Add(k, v)
1207+
}
11411208
lreq, _, status, err := buildLogicalRequest(core, nil, req, "")
1209+
if tc.wantError {
1210+
require.Error(t, err)
1211+
return
1212+
}
11421213
require.NoError(t, err)
11431214
require.Equal(t, 0, status)
11441215
require.Equal(t, tc.wantOperation, lreq.Operation)

sdk/framework/openapi.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -403,15 +403,21 @@ func documentPath(p *Path, backend *Backend, requestResponsePrefix string, doc *
403403
Name: "recover_snapshot_id",
404404
Description: "Triggers a recover operation using the given snapshot ID. Request body is ignored when a recover operation is requested.",
405405
In: "query",
406+
Deprecated: true,
407+
Schema: &OASSchema{Type: "string"},
408+
}, OASParameter{
409+
Name: "X-Vault-Recover-Snapshot-Id",
410+
Description: "Triggers a recover operation using the given snapshot ID. Request body is ignored when a recover operation is requested.",
411+
In: "header",
406412
Schema: &OASSchema{Type: "string"},
407413
})
408414
// If there are path fields, it will also be possible to recover from a path with
409415
// different path field values than the request path.
410416
if len(pathFields) > 0 {
411417
op.Parameters = append(op.Parameters, OASParameter{
412-
Name: "recover_source_path",
413-
Description: "The source path to recover from. Only used if recover_snapshot_id parameter is also supplied. If not specified, the source path is assumed to be the same as the request path.",
414-
In: "query",
418+
Name: "X-Vault-Recover-Source-Path",
419+
Description: "The source path to recover from. Only used if a snapshot ID is also supplied. If not specified, the source path is assumed to be the same as the request path.",
420+
In: "header",
415421
Schema: &OASSchema{Type: "string"},
416422
})
417423
}

sdk/framework/testdata/operations.json

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,23 @@
7171
"name": "recover_snapshot_id",
7272
"description": "Triggers a recover operation using the given snapshot ID. Request body is ignored when a recover operation is requested.",
7373
"in": "query",
74+
"schema": {
75+
"type": "string"
76+
},
77+
"deprecated": true
78+
},
79+
{
80+
"name": "X-Vault-Recover-Snapshot-Id",
81+
"description": "Triggers a recover operation using the given snapshot ID. Request body is ignored when a recover operation is requested.",
82+
"in": "header",
7483
"schema": {
7584
"type": "string"
7685
}
7786
},
7887
{
79-
"name": "recover_source_path",
80-
"description": "The source path to recover from. Only used if recover_snapshot_id parameter is also supplied. If not specified, the source path is assumed to be the same as the request path.",
81-
"in": "query",
88+
"name": "X-Vault-Recover-Source-Path",
89+
"description": "The source path to recover from. Only used if a snapshot ID is also supplied. If not specified, the source path is assumed to be the same as the request path.",
90+
"in": "header",
8291
"schema": {
8392
"type": "string"
8493
}

sdk/framework/testdata/operations_recover.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,20 @@
4343
"parameters": [
4444
{
4545
"name": "recover_snapshot_id",
46+
"deprecated": true,
4647
"description": "Triggers a recover operation using the given snapshot ID. Request body is ignored when a recover operation is requested.",
4748
"in": "query",
4849
"schema": {
4950
"type": "string"
5051
}
52+
},
53+
{
54+
"name": "X-Vault-Recover-Snapshot-Id",
55+
"description": "Triggers a recover operation using the given snapshot ID. Request body is ignored when a recover operation is requested.",
56+
"in": "header",
57+
"schema": {
58+
"type": "string"
59+
}
5160
}
5261
],
5362
"requestBody": {

sdk/helper/consts/consts.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,5 @@ const (
4848

4949
DRReplicationPathTarget = "dr"
5050

51-
RecoverSourcePathParam = "recover_source_path"
51+
RecoverSourcePathHeader = "X-Vault-Recover-Source-Path"
5252
)

0 commit comments

Comments
 (0)