Skip to content

Commit d95e824

Browse files
committed
Fix secret name encoding, Retry-After handling, and site_url validation
1 parent e6b1b3f commit d95e824

7 files changed

Lines changed: 124 additions & 18 deletions

File tree

lib/infisical/client.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "uri"
4+
35
require_relative "http_client"
46
require_relative "auth"
57
require_relative "secrets"
@@ -9,8 +11,10 @@ module Infisical
911
# use `secrets` (and future resource clients) to talk to Infisical.
1012
class Client
1113
DEFAULT_SITE_URL = "https://app.infisical.com"
14+
ALLOWED_SCHEMES = %w[http https].freeze
1215

1316
def initialize(site_url: DEFAULT_SITE_URL, timeout: HTTPClient::DEFAULT_TIMEOUT)
17+
validate_site_url!(site_url)
1418
@http_client = HTTPClient.new(base_url: site_url, timeout: timeout)
1519
end
1620

@@ -21,5 +25,16 @@ def auth
2125
def secrets
2226
@secrets ||= Secrets.new(@http_client)
2327
end
28+
29+
private
30+
31+
def validate_site_url!(site_url)
32+
uri = URI.parse(site_url)
33+
return if ALLOWED_SCHEMES.include?(uri.scheme) && uri.host
34+
35+
raise ArgumentError, "site_url must be an http(s) URL, got: #{site_url.inspect}"
36+
rescue URI::InvalidURIError
37+
raise ArgumentError, "site_url is not a valid URL: #{site_url.inspect}"
38+
end
2439
end
2540
end

lib/infisical/errors.rb

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,8 @@ def initialize(message, status:, url:, method:, request_id: nil)
2222
end
2323

2424
# Raised when the request itself fails (network/timeout) before a
25-
# response is received.
26-
class RequestError < Error
27-
def initialize(message, cause: nil)
28-
super(message)
29-
@cause = cause if cause
30-
end
31-
end
25+
# response is received. Always raised from inside a `rescue` for the
26+
# underlying network exception, so Ruby's built-in `Exception#cause`
27+
# already carries it through — no need to track it ourselves.
28+
class RequestError < Error; end
3229
end

lib/infisical/http_client.rb

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "net/http"
44
require "uri"
55
require "json"
6+
require "time"
67

78
require_relative "errors"
89

@@ -73,7 +74,7 @@ def request(method, path, body: nil, params: {})
7374
response = perform(method, uri, body)
7475
handle_response(response, method: method, uri: uri)
7576
rescue *RETRYABLE_EXCEPTIONS => e
76-
raise RequestError.new("request to #{uri} failed: #{e.message}", cause: e) if attempt >= @max_retries
77+
raise RequestError, "request to #{uri} failed: #{e.message}" if attempt >= @max_retries
7778

7879
attempt += 1
7980
wait_before_retry(attempt)
@@ -82,7 +83,7 @@ def request(method, path, body: nil, params: {})
8283
raise e.api_error if attempt >= @max_retries
8384

8485
attempt += 1
85-
wait_before_retry(attempt)
86+
wait_before_retry(attempt, override: e.retry_after)
8687
retry
8788
end
8889
end
@@ -126,12 +127,32 @@ def build_request(method, uri, body)
126127

127128
def handle_response(response, method:, uri:)
128129
status = response.code.to_i
129-
raise RetryableAPIError, build_api_error(response, status, method, uri) if status == 429
130+
if status == 429
131+
raise RetryableAPIError.new(build_api_error(response, status, method, uri),
132+
retry_after: parse_retry_after(response["Retry-After"]))
133+
end
130134
raise build_api_error(response, status, method, uri) unless (200..299).cover?(status)
131135

132136
parse_body(response.body)
133137
end
134138

139+
# Retry-After is usually an integer/float number of seconds, but per
140+
# RFC 9110 it may also be an HTTP-date.
141+
def parse_retry_after(value)
142+
return nil if value.nil? || value.empty?
143+
144+
begin
145+
Float(value)
146+
rescue ArgumentError, TypeError
147+
begin
148+
seconds = Time.httpdate(value) - Time.now
149+
seconds.positive? ? seconds : nil
150+
rescue ArgumentError
151+
nil
152+
end
153+
end
154+
end
155+
135156
def build_api_error(response, status, method, uri)
136157
data = parse_body(response.body)
137158
message = data.is_a?(Hash) ? (data["message"] || data["error"] || response.body) : response.body
@@ -153,19 +174,27 @@ def parse_body(raw)
153174
raw
154175
end
155176

156-
def wait_before_retry(attempt)
177+
def wait_before_retry(attempt, override: nil)
178+
if override
179+
@sleeper.call(override)
180+
return
181+
end
182+
157183
base = self.class.backoff_delay(attempt - 1, initial_delay: @initial_delay, backoff_factor: @backoff_factor)
158184
jitter = base * JITTER_RATIO * ((rand * 2) - 1)
159185
@sleeper.call(base + jitter)
160186
end
161187

162188
# Internal-only signal so 429s share the same retry path as network
163-
# errors without retrying every other 4xx/5xx status.
189+
# errors without retrying every other 4xx/5xx status. Carries the
190+
# server's Retry-After hint (if any) so the wait honors it instead of
191+
# our own computed backoff.
164192
class RetryableAPIError < StandardError
165-
attr_reader :api_error
193+
attr_reader :api_error, :retry_after
166194

167-
def initialize(api_error)
195+
def initialize(api_error, retry_after: nil)
168196
@api_error = api_error
197+
@retry_after = retry_after
169198
super(api_error.message)
170199
end
171200
end

lib/infisical/secrets.rb

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require "uri"
4+
35
require_relative "models/secret"
46

57
module Infisical
@@ -31,7 +33,7 @@ def list(project_id:, environment:, secret_path: "/", expand_secret_references:
3133
def get(secret_name, project_id:, environment:, secret_path: "/",
3234
expand_secret_references: true, include_imports: false)
3335
response = @http_client.get(
34-
"#{BASE_PATH}/#{secret_name}",
36+
secret_path_for(secret_name),
3537
params: {
3638
workspaceId: project_id,
3739
environment: environment,
@@ -46,7 +48,7 @@ def get(secret_name, project_id:, environment:, secret_path: "/",
4648

4749
def create(secret_name, secret_value, project_id:, environment:, secret_path: "/", secret_comment: nil)
4850
response = @http_client.post(
49-
"#{BASE_PATH}/#{secret_name}",
51+
secret_path_for(secret_name),
5052
body: {
5153
workspaceId: project_id,
5254
environment: environment,
@@ -60,8 +62,12 @@ def create(secret_name, secret_value, project_id:, environment:, secret_path: "/
6062
end
6163

6264
def update(secret_name, project_id:, environment:, secret_value: nil, new_secret_name: nil, secret_path: "/")
65+
if secret_value.nil? && new_secret_name.nil?
66+
raise ArgumentError, "update requires at least one of secret_value: or new_secret_name:"
67+
end
68+
6369
response = @http_client.patch(
64-
"#{BASE_PATH}/#{secret_name}",
70+
secret_path_for(secret_name),
6571
body: {
6672
workspaceId: project_id,
6773
environment: environment,
@@ -76,7 +82,7 @@ def update(secret_name, project_id:, environment:, secret_value: nil, new_secret
7682

7783
def delete(secret_name, project_id:, environment:, secret_path: "/")
7884
response = @http_client.delete(
79-
"#{BASE_PATH}/#{secret_name}",
85+
secret_path_for(secret_name),
8086
body: {
8187
workspaceId: project_id,
8288
environment: environment,
@@ -86,5 +92,14 @@ def delete(secret_name, project_id:, environment:, secret_path: "/")
8692

8793
Models::Secret.from_api(response["secret"])
8894
end
95+
96+
private
97+
98+
# Escapes a secret name for safe use as a single URI path segment, so
99+
# names containing "/", "?", "#", or "%" can't be misread as path
100+
# separators or query-string tokens.
101+
def secret_path_for(secret_name)
102+
"#{BASE_PATH}/#{URI::DEFAULT_PARSER.escape(secret_name.to_s, /[^A-Za-z0-9\-._~]/)}"
103+
end
89104
end
90105
end

spec/infisical/client_spec.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Infisical::Client do
4+
it "builds successfully with the default site_url" do
5+
expect { described_class.new }.not_to raise_error
6+
end
7+
8+
it "builds successfully with a valid https site_url" do
9+
expect { described_class.new(site_url: "https://self-hosted.example.com") }.not_to raise_error
10+
end
11+
12+
it "rejects a non-http(s) scheme" do
13+
expect { described_class.new(site_url: "file:///etc/passwd") }
14+
.to raise_error(ArgumentError, /must be an http\(s\) URL/)
15+
end
16+
17+
it "rejects a malformed URL" do
18+
expect { described_class.new(site_url: "not a url") }
19+
.to raise_error(ArgumentError, /not a valid URL/)
20+
end
21+
end

spec/infisical/http_client_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@
6464
expect(stub).to have_been_requested.times(2)
6565
end
6666

67+
it "honors a Retry-After header instead of computing its own backoff" do
68+
waits = []
69+
patient_client = described_class.new(base_url: base_url, max_retries: 1, sleeper: lambda { |seconds|
70+
waits << seconds
71+
})
72+
stub_request(:get, url).to_return(
73+
{ status: 429, body: '{"message":"rate limited"}', headers: { "Retry-After" => "5" } },
74+
{ status: 200, body: '{"secrets":[]}' }
75+
)
76+
77+
expect(patient_client.get("api/v3/secrets/raw")).to eq({ "secrets" => [] })
78+
expect(waits).to eq([5.0])
79+
end
80+
6781
it "raises Infisical::APIError after exhausting retries on persistent 429s" do
6882
stub = stub_request(:get, url).to_return(status: 429, body: '{"message":"rate limited"}')
6983

spec/infisical/secrets_spec.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@
3535
expect(secret.secret_key).to eq("FOO")
3636
expect(secret.secret_value).to eq("bar")
3737
end
38+
39+
it "URL-encodes secret names containing reserved characters" do
40+
stub = stub_request(:get, "#{base_url}/api/v3/secrets/raw/FOO%2FBAR")
41+
.with(query: hash_including("workspaceId" => "proj-1"))
42+
.to_return(status: 200, body: { secret: { id: "1", secretKey: "FOO/BAR" } }.to_json)
43+
44+
secrets.get("FOO/BAR", project_id: "proj-1", environment: "dev")
45+
46+
expect(stub).to have_been_requested
47+
end
3848
end
3949

4050
describe "#create" do
@@ -71,6 +81,11 @@
7181

7282
expect(stub).to have_been_requested
7383
end
84+
85+
it "raises ArgumentError when neither secret_value nor new_secret_name is given" do
86+
expect { secrets.update("FOO", project_id: "proj-1", environment: "dev") }
87+
.to raise_error(ArgumentError, /requires at least one of/)
88+
end
7489
end
7590

7691
describe "#delete" do

0 commit comments

Comments
 (0)