Skip to content

Commit 3511bd1

Browse files
committed
fix: prevent passing negative expiration during secret creation
Signed-off-by: Knut Ahlers <knut@ahlers.me>
1 parent 345bd0d commit 3511bd1

2 files changed

Lines changed: 179 additions & 3 deletions

File tree

api.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ import (
1616
)
1717

1818
const (
19+
errorReasonInvalidExpiry = "invalid_expiry"
1920
errorReasonInvalidJSON = "invalid_json"
2021
errorReasonSecretMissing = "secret_missing"
22+
errorReasonSecretNotFound = "secret_not_found"
2123
errorReasonSecretSize = "secret_size"
2224
errorReasonStorageError = "storage_error"
23-
errorReasonSecretNotFound = "secret_not_found"
25+
26+
maxExpirySeconds = int64(1<<63-1) / int64(time.Second)
2427
)
2528

2629
type apiServer struct {
@@ -69,8 +72,11 @@ func (a apiServer) handleCreate(res http.ResponseWriter, r *http.Request) {
6972
)
7073

7174
if !cust.DisableExpiryOverride {
72-
if ev, err := strconv.ParseInt(r.URL.Query().Get("expire"), 10, 64); err == nil && (ev < expiry || cfg.SecretExpiry == 0) {
73-
expiry = ev
75+
var err error
76+
if expiry, err = a.parseExpiryOverride(r, expiry); err != nil {
77+
a.collector.CountSecretCreateError(errorReasonInvalidExpiry)
78+
a.errorResponse(res, http.StatusBadRequest, err, "")
79+
return
7480
}
7581
}
7682

@@ -183,3 +189,29 @@ func (apiServer) jsonResponse(res http.ResponseWriter, status int, response any)
183189
http.Error(res, `{"error":"could not encode response"}`, http.StatusInternalServerError)
184190
}
185191
}
192+
193+
func (apiServer) parseExpiryOverride(r *http.Request, expiry int64) (int64, error) {
194+
expiryValues, ok := r.URL.Query()["expire"]
195+
if !ok {
196+
return expiry, nil
197+
}
198+
199+
ev, err := strconv.ParseInt(expiryValues[0], 10, 64)
200+
if err != nil {
201+
return 0, errors.New("invalid expiry")
202+
}
203+
204+
if ev < 0 {
205+
return 0, errors.New("expiry must be greater than or equal to zero")
206+
}
207+
208+
if ev > maxExpirySeconds {
209+
return 0, errors.New("expiry exceeds maximum duration")
210+
}
211+
212+
if ev < expiry || cfg.SecretExpiry == 0 {
213+
return ev, nil
214+
}
215+
216+
return expiry, nil
217+
}

api_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"net/http/httptest"
10+
"strconv"
11+
"strings"
12+
"testing"
13+
14+
"github.com/Luzifer/ots/pkg/customization"
15+
"github.com/Luzifer/ots/pkg/metrics"
16+
"github.com/Luzifer/ots/pkg/storage"
17+
"github.com/Luzifer/ots/pkg/storage/memory"
18+
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
20+
)
21+
22+
var testCollector = metrics.New()
23+
24+
func TestHandleCreateExpiryOverrideAcceptedValues(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
expire int64
28+
wantExpiresAt bool
29+
}{
30+
{
31+
name: "zero",
32+
expire: 0,
33+
wantExpiresAt: false,
34+
},
35+
{
36+
name: "one-second",
37+
expire: 1,
38+
wantExpiresAt: true,
39+
},
40+
}
41+
42+
for _, tc := range tests {
43+
t.Run(tc.name, func(t *testing.T) {
44+
api, _ := newTestAPI(t)
45+
res := createJSONSecret(api, fmt.Sprintf("/api/create?expire=%d", tc.expire))
46+
47+
require.Equal(t, http.StatusCreated, res.Code)
48+
49+
var response apiResponse
50+
require.NoError(t, json.NewDecoder(res.Body).Decode(&response))
51+
assert.True(t, response.Success)
52+
assert.NotEmpty(t, response.SecretID)
53+
if tc.wantExpiresAt {
54+
assert.NotNil(t, response.ExpiresAt)
55+
} else {
56+
assert.Nil(t, response.ExpiresAt)
57+
}
58+
})
59+
}
60+
}
61+
62+
func TestHandleCreateExpiryOverrideValidation(t *testing.T) {
63+
tests := []struct {
64+
name string
65+
contentType string
66+
body string
67+
expire string
68+
}{
69+
{
70+
name: "empty",
71+
contentType: "application/json",
72+
body: `{"secret":"test-secret"}`,
73+
expire: "",
74+
},
75+
{
76+
name: "malformed",
77+
contentType: "application/json",
78+
body: `{"secret":"test-secret"}`,
79+
expire: "abc",
80+
},
81+
{
82+
name: "negative-json",
83+
contentType: "application/json",
84+
body: `{"secret":"test-secret"}`,
85+
expire: "-1",
86+
},
87+
{
88+
name: "negative-form",
89+
contentType: "application/x-www-form-urlencoded",
90+
body: "secret=test-secret",
91+
expire: "-1",
92+
},
93+
{
94+
name: "too-large",
95+
contentType: "application/json",
96+
body: `{"secret":"test-secret"}`,
97+
expire: strconv.FormatInt(maxExpirySeconds+1, 10),
98+
},
99+
}
100+
101+
for _, tc := range tests {
102+
t.Run(tc.name, func(t *testing.T) {
103+
api, store := newTestAPI(t)
104+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/create?expire="+tc.expire, strings.NewReader(tc.body))
105+
req.Header.Set("Content-Type", tc.contentType)
106+
res := httptest.NewRecorder()
107+
108+
api.handleCreate(res, req)
109+
110+
require.Equal(t, http.StatusBadRequest, res.Code)
111+
112+
count, err := store.Count()
113+
require.NoError(t, err)
114+
assert.Zero(t, count)
115+
})
116+
}
117+
}
118+
119+
func createJSONSecret(api *apiServer, target string) *httptest.ResponseRecorder {
120+
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, target, bytes.NewBufferString(`{"secret":"test-secret"}`))
121+
req.Header.Set("Content-Type", "application/json")
122+
123+
res := httptest.NewRecorder()
124+
api.handleCreate(res, req)
125+
126+
return res
127+
}
128+
129+
func newTestAPI(t *testing.T) (*apiServer, storage.Storage) {
130+
t.Helper()
131+
132+
oldCfg := cfg
133+
oldCust := cust
134+
t.Cleanup(func() {
135+
cfg = oldCfg
136+
cust = oldCust
137+
})
138+
139+
cfg.SecretExpiry = 3600
140+
cust = customization.Customize{}
141+
142+
store := memory.New()
143+
return newAPI(store, testCollector), store
144+
}

0 commit comments

Comments
 (0)