haveapi-go-client 0.27.3 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/haveapi-go-client.gemspec +1 -1
- data/lib/haveapi/go_client/action.rb +7 -3
- data/lib/haveapi/go_client/erb_template.rb +3 -0
- data/lib/haveapi/go_client/generator.rb +5 -2
- data/lib/haveapi/go_client/resource.rb +7 -2
- data/lib/haveapi/go_client/utils.rb +101 -1
- data/lib/haveapi/go_client/version.rb +1 -1
- data/spec/integration/generator_spec.rb +654 -2
- data/template/action.go.erb +74 -73
- data/template/authentication/oauth2.go.erb +20 -8
- data/template/authentication/token.go.erb +10 -10
- data/template/client.go.erb +41 -2
- data/template/request.go.erb +106 -8
- data/template/resource.go.erb +3 -3
- metadata +3 -4
- data/shell.nix +0 -23
|
@@ -7,6 +7,63 @@ RSpec.describe HaveAPI::GoClient::Generator do
|
|
|
7
7
|
let(:root) { File.expand_path('../../../..', __dir__) }
|
|
8
8
|
let(:cwd) { File.join(root, 'clients', 'go') }
|
|
9
9
|
|
|
10
|
+
def oauth2_action_description(method:, path:, output_params:)
|
|
11
|
+
{
|
|
12
|
+
aliases: [],
|
|
13
|
+
input: nil,
|
|
14
|
+
output: {
|
|
15
|
+
layout: 'object',
|
|
16
|
+
namespace: 'action_state',
|
|
17
|
+
parameters: output_params
|
|
18
|
+
},
|
|
19
|
+
method:,
|
|
20
|
+
path:,
|
|
21
|
+
meta: {},
|
|
22
|
+
blocking: false
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def oauth2_revoke_description(revoke_url: '/revoke')
|
|
27
|
+
{
|
|
28
|
+
authentication: {
|
|
29
|
+
oauth2: {
|
|
30
|
+
http_header: 'X-HaveAPI-OAuth2-Token',
|
|
31
|
+
revoke_url:
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
meta: { namespace: '_meta' },
|
|
35
|
+
resources: {
|
|
36
|
+
action_state: {
|
|
37
|
+
resources: {},
|
|
38
|
+
actions: {
|
|
39
|
+
show: oauth2_action_description(
|
|
40
|
+
method: 'GET',
|
|
41
|
+
path: '/action_states/{action_state_id}',
|
|
42
|
+
output_params: { id: { type: 'Integer' } }
|
|
43
|
+
),
|
|
44
|
+
poll: oauth2_action_description(
|
|
45
|
+
method: 'GET',
|
|
46
|
+
path: '/action_states/{action_state_id}/poll',
|
|
47
|
+
output_params: { finished: { type: 'Boolean' } }
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def action_description(method:, path:, aliases: [], input: nil, output: nil, meta: {}, blocking: false)
|
|
56
|
+
{
|
|
57
|
+
aliases:,
|
|
58
|
+
input:,
|
|
59
|
+
output:,
|
|
60
|
+
method:,
|
|
61
|
+
path:,
|
|
62
|
+
meta:,
|
|
63
|
+
blocking:
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
10
67
|
it 'generates a client that compiles and can call the API' do
|
|
11
68
|
Dir.mktmpdir('haveapi-go-client-') do |dir|
|
|
12
69
|
cmd = [
|
|
@@ -24,7 +81,11 @@ RSpec.describe HaveAPI::GoClient::Generator do
|
|
|
24
81
|
File.write(File.join(dir, 'client', 'client_integration_test.go'), <<~GO)
|
|
25
82
|
package client
|
|
26
83
|
|
|
27
|
-
import
|
|
84
|
+
import (
|
|
85
|
+
"net/http"
|
|
86
|
+
"testing"
|
|
87
|
+
"time"
|
|
88
|
+
)
|
|
28
89
|
|
|
29
90
|
func TestProjectList(t *testing.T) {
|
|
30
91
|
c := New("#{base_url}")
|
|
@@ -43,6 +104,28 @@ RSpec.describe HaveAPI::GoClient::Generator do
|
|
|
43
104
|
t.Fatalf("expected at least 2 projects, got %d", len(resp.Output))
|
|
44
105
|
}
|
|
45
106
|
}
|
|
107
|
+
|
|
108
|
+
func TestClientTimeout(t *testing.T) {
|
|
109
|
+
c := New("#{base_url}")
|
|
110
|
+
c.SetBasicAuthentication("user", "pass")
|
|
111
|
+
c.SetTimeout(10 * time.Millisecond)
|
|
112
|
+
|
|
113
|
+
_, err := c.Test.Slow.Prepare().Call()
|
|
114
|
+
if err == nil {
|
|
115
|
+
t.Fatalf("expected timeout error, got nil")
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func TestClientHTTPClient(t *testing.T) {
|
|
120
|
+
c := New("#{base_url}")
|
|
121
|
+
c.SetBasicAuthentication("user", "pass")
|
|
122
|
+
c.SetHTTPClient(&http.Client{Timeout: 10 * time.Millisecond})
|
|
123
|
+
|
|
124
|
+
_, err := c.Test.Slow.Prepare().Call()
|
|
125
|
+
if err == nil {
|
|
126
|
+
t.Fatalf("expected timeout error, got nil")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
46
129
|
GO
|
|
47
130
|
|
|
48
131
|
File.write(File.join(dir, 'client', 'client_validation_test.go'), <<~GO)
|
|
@@ -226,7 +309,576 @@ RSpec.describe HaveAPI::GoClient::Generator do
|
|
|
226
309
|
}
|
|
227
310
|
GO
|
|
228
311
|
|
|
229
|
-
go_out, go_err, go_status = Open3.capture3(
|
|
312
|
+
go_out, go_err, go_status = Open3.capture3(
|
|
313
|
+
{ 'CGO_ENABLED' => '0' },
|
|
314
|
+
'go',
|
|
315
|
+
'test',
|
|
316
|
+
'./...',
|
|
317
|
+
chdir: dir
|
|
318
|
+
)
|
|
319
|
+
expect(go_status).to be_success, "go test failed: #{go_out}\n#{go_err}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
it 'generates an OAuth2 client that sends the revoke token as form data' do
|
|
324
|
+
Dir.mktmpdir('haveapi-go-client-oauth2-') do |dir|
|
|
325
|
+
communicator = instance_double(
|
|
326
|
+
HaveAPI::Client::Communicator,
|
|
327
|
+
describe_api: oauth2_revoke_description
|
|
328
|
+
)
|
|
329
|
+
allow(HaveAPI::Client::Communicator).to receive(:new).and_return(communicator)
|
|
330
|
+
|
|
331
|
+
generator = described_class.new(
|
|
332
|
+
'http://unused.example',
|
|
333
|
+
dir,
|
|
334
|
+
module: 'example.com/haveapi-oauth2-revoke',
|
|
335
|
+
package: 'client'
|
|
336
|
+
)
|
|
337
|
+
generator.generate
|
|
338
|
+
generator.go_fmt
|
|
339
|
+
|
|
340
|
+
File.write(File.join(dir, 'client', 'oauth2_revoke_test.go'), <<~GO)
|
|
341
|
+
package client
|
|
342
|
+
|
|
343
|
+
import (
|
|
344
|
+
"io"
|
|
345
|
+
"net/http"
|
|
346
|
+
"net/url"
|
|
347
|
+
"strings"
|
|
348
|
+
"testing"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
type captureTransport struct {
|
|
352
|
+
req *http.Request
|
|
353
|
+
body string
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
func (transport *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
357
|
+
if req.Body != nil {
|
|
358
|
+
body, err := io.ReadAll(req.Body)
|
|
359
|
+
if err != nil {
|
|
360
|
+
return nil, err
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if err := req.Body.Close(); err != nil {
|
|
364
|
+
return nil, err
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
transport.body = string(body)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
transport.req = req
|
|
371
|
+
|
|
372
|
+
return &http.Response{
|
|
373
|
+
StatusCode: 200,
|
|
374
|
+
Status: "200 OK",
|
|
375
|
+
Body: io.NopCloser(strings.NewReader("ok")),
|
|
376
|
+
Header: make(http.Header),
|
|
377
|
+
Request: req,
|
|
378
|
+
}, nil
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
func TestOAuth2RevokeSendsTokenFormBody(t *testing.T) {
|
|
382
|
+
transport := &captureTransport{}
|
|
383
|
+
token := "secret-token"
|
|
384
|
+
|
|
385
|
+
c := New("http://unused.example")
|
|
386
|
+
c.SetHTTPClient(&http.Client{Transport: transport})
|
|
387
|
+
c.SetExistingOAuth2Auth(token)
|
|
388
|
+
|
|
389
|
+
if err := c.RevokeAccessToken(); err != nil {
|
|
390
|
+
t.Fatalf("revoke failed: %v", err)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if transport.req == nil {
|
|
394
|
+
t.Fatalf("expected revoke request")
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if got := transport.req.Method; got != "POST" {
|
|
398
|
+
t.Fatalf("expected POST revoke, got %s", got)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if got := transport.req.URL.String(); got != "http://unused.example/revoke" {
|
|
402
|
+
t.Fatalf("unexpected revoke URL: %s", got)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if got := transport.req.Header.Get("X-HaveAPI-OAuth2-Token"); got != token {
|
|
406
|
+
t.Fatalf("expected OAuth2 header %q, got %q", token, got)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if got := transport.req.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
|
410
|
+
t.Fatalf("expected form content type, got %q", got)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
form, err := url.ParseQuery(transport.body)
|
|
414
|
+
if err != nil {
|
|
415
|
+
t.Fatalf("invalid form body %q: %v", transport.body, err)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if got := form.Get("token"); got != token {
|
|
419
|
+
t.Fatalf("expected revoke token %q, got %q in body %q", token, got, transport.body)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if c.Authentication != nil {
|
|
423
|
+
t.Fatalf("expected authentication to be cleared after revoke")
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
GO
|
|
427
|
+
|
|
428
|
+
go_out, go_err, go_status = Open3.capture3(
|
|
429
|
+
{ 'CGO_ENABLED' => '0' },
|
|
430
|
+
'go',
|
|
431
|
+
'test',
|
|
432
|
+
'./...',
|
|
433
|
+
chdir: dir
|
|
434
|
+
)
|
|
435
|
+
expect(go_status).to be_success, "go test failed: #{go_out}\n#{go_err}"
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
it 'rejects generated action paths that can switch request authority' do
|
|
440
|
+
malicious_description = {
|
|
441
|
+
authentication: {
|
|
442
|
+
basic: {}
|
|
443
|
+
},
|
|
444
|
+
meta: { namespace: '_meta' },
|
|
445
|
+
resources: {
|
|
446
|
+
action_state: {
|
|
447
|
+
resources: {},
|
|
448
|
+
actions: {
|
|
449
|
+
show: oauth2_action_description(
|
|
450
|
+
method: 'GET',
|
|
451
|
+
path: '/action_states/{action_state_id}',
|
|
452
|
+
output_params: { id: { type: 'Integer' } }
|
|
453
|
+
),
|
|
454
|
+
poll: oauth2_action_description(
|
|
455
|
+
method: 'GET',
|
|
456
|
+
path: '/action_states/{action_state_id}/poll',
|
|
457
|
+
output_params: { finished: { type: 'Boolean' } }
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
victim: {
|
|
462
|
+
resources: {},
|
|
463
|
+
actions: {
|
|
464
|
+
list: action_description(method: 'GET', path: '@attacker.example/capture')
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
Dir.mktmpdir('haveapi-go-client-action-authority-') do |dir|
|
|
471
|
+
communicator = instance_double(
|
|
472
|
+
HaveAPI::Client::Communicator,
|
|
473
|
+
describe_api: malicious_description
|
|
474
|
+
)
|
|
475
|
+
allow(HaveAPI::Client::Communicator).to receive(:new).and_return(communicator)
|
|
476
|
+
|
|
477
|
+
generator = described_class.new(
|
|
478
|
+
'http://api.example',
|
|
479
|
+
dir,
|
|
480
|
+
module: 'example.com/haveapi-action-authority',
|
|
481
|
+
package: 'client'
|
|
482
|
+
)
|
|
483
|
+
generator.generate
|
|
484
|
+
generator.go_fmt
|
|
485
|
+
|
|
486
|
+
File.write(File.join(dir, 'client', 'action_authority_test.go'), <<~GO)
|
|
487
|
+
package client
|
|
488
|
+
|
|
489
|
+
import (
|
|
490
|
+
"errors"
|
|
491
|
+
"net/http"
|
|
492
|
+
"testing"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
type unexpectedActionTransport struct {
|
|
496
|
+
req *http.Request
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
func (transport *unexpectedActionTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
500
|
+
transport.req = req
|
|
501
|
+
return nil, errors.New("unexpected request")
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
func TestActionPathAuthoritySwitchIsRejectedBeforeAuth(t *testing.T) {
|
|
505
|
+
transport := &unexpectedActionTransport{}
|
|
506
|
+
|
|
507
|
+
c := New("http://api.example")
|
|
508
|
+
c.SetHTTPClient(&http.Client{Transport: transport})
|
|
509
|
+
c.SetBasicAuthentication("user", "pass")
|
|
510
|
+
|
|
511
|
+
if _, err := c.Victim.List.Call(); err == nil {
|
|
512
|
+
t.Fatalf("expected unsafe action path error")
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if transport.req != nil {
|
|
516
|
+
t.Fatalf(
|
|
517
|
+
"unexpected request to %s with authorization %q",
|
|
518
|
+
transport.req.URL.String(),
|
|
519
|
+
transport.req.Header.Get("Authorization"),
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
GO
|
|
524
|
+
|
|
525
|
+
go_out, go_err, go_status = Open3.capture3(
|
|
526
|
+
{ 'CGO_ENABLED' => '0' },
|
|
527
|
+
'go',
|
|
528
|
+
'test',
|
|
529
|
+
'./...',
|
|
530
|
+
chdir: dir
|
|
531
|
+
)
|
|
532
|
+
expect(go_status).to be_success, "go test failed: #{go_out}\n#{go_err}"
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
it 'escapes generated action path parameters as single path segments' do
|
|
537
|
+
description = {
|
|
538
|
+
authentication: {
|
|
539
|
+
basic: {}
|
|
540
|
+
},
|
|
541
|
+
meta: { namespace: '_meta' },
|
|
542
|
+
resources: {
|
|
543
|
+
action_state: {
|
|
544
|
+
resources: {},
|
|
545
|
+
actions: {
|
|
546
|
+
show: oauth2_action_description(
|
|
547
|
+
method: 'GET',
|
|
548
|
+
path: '/action_states/{action_state_id}',
|
|
549
|
+
output_params: { id: { type: 'Integer' } }
|
|
550
|
+
),
|
|
551
|
+
poll: oauth2_action_description(
|
|
552
|
+
method: 'GET',
|
|
553
|
+
path: '/action_states/{action_state_id}/poll',
|
|
554
|
+
output_params: { finished: { type: 'Boolean' } }
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
user: {
|
|
559
|
+
resources: {},
|
|
560
|
+
actions: {
|
|
561
|
+
show: action_description(
|
|
562
|
+
method: 'GET',
|
|
563
|
+
path: '/users/{user_id}',
|
|
564
|
+
output: {
|
|
565
|
+
layout: 'object',
|
|
566
|
+
namespace: 'user',
|
|
567
|
+
parameters: { id: { type: 'Integer' } }
|
|
568
|
+
}
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
Dir.mktmpdir('haveapi-go-client-path-param-') do |dir|
|
|
576
|
+
communicator = instance_double(
|
|
577
|
+
HaveAPI::Client::Communicator,
|
|
578
|
+
describe_api: description
|
|
579
|
+
)
|
|
580
|
+
allow(HaveAPI::Client::Communicator).to receive(:new).and_return(communicator)
|
|
581
|
+
|
|
582
|
+
generator = described_class.new(
|
|
583
|
+
'http://api.example',
|
|
584
|
+
dir,
|
|
585
|
+
module: 'example.com/haveapi-path-param',
|
|
586
|
+
package: 'client'
|
|
587
|
+
)
|
|
588
|
+
generator.generate
|
|
589
|
+
generator.go_fmt
|
|
590
|
+
|
|
591
|
+
File.write(File.join(dir, 'client', 'path_param_escape_test.go'), <<~GO)
|
|
592
|
+
package client
|
|
593
|
+
|
|
594
|
+
import (
|
|
595
|
+
"io"
|
|
596
|
+
"net/http"
|
|
597
|
+
"net/url"
|
|
598
|
+
"strings"
|
|
599
|
+
"testing"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
type pathParamEscapeTransport struct {
|
|
603
|
+
req *http.Request
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
func (transport *pathParamEscapeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
607
|
+
transport.req = req
|
|
608
|
+
|
|
609
|
+
return &http.Response{
|
|
610
|
+
StatusCode: 200,
|
|
611
|
+
Status: "200 OK",
|
|
612
|
+
Body: io.NopCloser(strings.NewReader(`{"status":true,"response":{"user":{"id":42}}}`)),
|
|
613
|
+
Header: make(http.Header),
|
|
614
|
+
Request: req,
|
|
615
|
+
}, nil
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
func TestPathParamIsEscapedAsSingleSegment(t *testing.T) {
|
|
619
|
+
transport := &pathParamEscapeTransport{}
|
|
620
|
+
pathArg := "42?user[name]=alice&_meta[includes]=group__secret/section#frag%2Fencoded=1"
|
|
621
|
+
|
|
622
|
+
c := New("http://api.example")
|
|
623
|
+
c.SetHTTPClient(&http.Client{Transport: transport})
|
|
624
|
+
|
|
625
|
+
resp, err := c.User.Show.Prepare().SetPathParamString("user_id", pathArg).Call()
|
|
626
|
+
if err != nil {
|
|
627
|
+
t.Fatalf("request failed: %v", err)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if !resp.Status {
|
|
631
|
+
t.Fatalf("request failed: %s", resp.Message)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if transport.req == nil {
|
|
635
|
+
t.Fatalf("expected request")
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if got := transport.req.URL.RawQuery; got != "" {
|
|
639
|
+
t.Fatalf("path parameter injected query string %q", got)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if got := transport.req.URL.Fragment; got != "" {
|
|
643
|
+
t.Fatalf("path parameter injected fragment %q", got)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
expectedPath := "/users/" + url.PathEscape(pathArg)
|
|
647
|
+
if got := transport.req.URL.EscapedPath(); got != expectedPath {
|
|
648
|
+
t.Fatalf("expected escaped path %q, got %q", expectedPath, got)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if got := transport.req.URL.Query().Get("user[name]"); got != "" {
|
|
652
|
+
t.Fatalf("unexpected injected user[name] query value %q", got)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if got := transport.req.URL.Query().Get("_meta[includes]"); got != "" {
|
|
656
|
+
t.Fatalf("unexpected injected _meta[includes] query value %q", got)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if !strings.Contains(transport.req.URL.EscapedPath(), "%252Fencoded") {
|
|
660
|
+
t.Fatalf("expected percent-encoded input to remain data, got %q", transport.req.URL.EscapedPath())
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
GO
|
|
664
|
+
|
|
665
|
+
go_out, go_err, go_status = Open3.capture3(
|
|
666
|
+
{ 'CGO_ENABLED' => '0' },
|
|
667
|
+
'go',
|
|
668
|
+
'test',
|
|
669
|
+
'./...',
|
|
670
|
+
chdir: dir
|
|
671
|
+
)
|
|
672
|
+
expect(go_status).to be_success, "go test failed: #{go_out}\n#{go_err}"
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
it 'rejects OAuth2 revoke URLs outside the client origin before sending the token' do
|
|
677
|
+
Dir.mktmpdir('haveapi-go-client-oauth2-revoke-origin-') do |dir|
|
|
678
|
+
communicator = instance_double(
|
|
679
|
+
HaveAPI::Client::Communicator,
|
|
680
|
+
describe_api: oauth2_revoke_description(
|
|
681
|
+
revoke_url: 'http://attacker.example/collect-token'
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
allow(HaveAPI::Client::Communicator).to receive(:new).and_return(communicator)
|
|
685
|
+
|
|
686
|
+
generator = described_class.new(
|
|
687
|
+
'http://api.example',
|
|
688
|
+
dir,
|
|
689
|
+
module: 'example.com/haveapi-oauth2-revoke-origin',
|
|
690
|
+
package: 'client'
|
|
691
|
+
)
|
|
692
|
+
generator.generate
|
|
693
|
+
generator.go_fmt
|
|
694
|
+
|
|
695
|
+
File.write(File.join(dir, 'client', 'oauth2_revoke_origin_test.go'), <<~GO)
|
|
696
|
+
package client
|
|
697
|
+
|
|
698
|
+
import (
|
|
699
|
+
"errors"
|
|
700
|
+
"net/http"
|
|
701
|
+
"testing"
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
type unexpectedRevokeTransport struct {
|
|
705
|
+
req *http.Request
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
func (transport *unexpectedRevokeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
709
|
+
transport.req = req
|
|
710
|
+
return nil, errors.New("unexpected request")
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
func TestOAuth2RevokeCrossOriginURLIsRejectedBeforeAuth(t *testing.T) {
|
|
714
|
+
transport := &unexpectedRevokeTransport{}
|
|
715
|
+
|
|
716
|
+
c := New("http://api.example")
|
|
717
|
+
c.SetHTTPClient(&http.Client{Transport: transport})
|
|
718
|
+
c.SetExistingOAuth2Auth("secret-token")
|
|
719
|
+
|
|
720
|
+
if err := c.RevokeAccessToken(); err == nil {
|
|
721
|
+
t.Fatalf("expected unsafe revoke URL error")
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if transport.req != nil {
|
|
725
|
+
t.Fatalf(
|
|
726
|
+
"unexpected request to %s with token header %q",
|
|
727
|
+
transport.req.URL.String(),
|
|
728
|
+
transport.req.Header.Get("X-HaveAPI-OAuth2-Token"),
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if c.Authentication == nil {
|
|
733
|
+
t.Fatalf("authentication should remain configured after failed revoke")
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
GO
|
|
737
|
+
|
|
738
|
+
go_out, go_err, go_status = Open3.capture3(
|
|
739
|
+
{ 'CGO_ENABLED' => '0' },
|
|
740
|
+
'go',
|
|
741
|
+
'test',
|
|
742
|
+
'./...',
|
|
743
|
+
chdir: dir
|
|
744
|
+
)
|
|
745
|
+
expect(go_status).to be_success, "go test failed: #{go_out}\n#{go_err}"
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
it 'escapes untrusted API descriptions when generating Go source' do
|
|
750
|
+
injected_path = <<~PATH.chomp
|
|
751
|
+
/safe",
|
|
752
|
+
\t}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
func init() {
|
|
756
|
+
\tpanic("generated code executed from API description")
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
func (action *ActionVictimList) unused() *ActionVictimListInvocation {
|
|
760
|
+
\treturn &ActionVictimListInvocation{
|
|
761
|
+
\t\tAction: action,
|
|
762
|
+
\t\tPath: "/safe
|
|
763
|
+
PATH
|
|
764
|
+
|
|
765
|
+
injected_name = "bad\"\nfunc init() { panic(\"name\") }\n"
|
|
766
|
+
injected_method = "POST\"\nfunc init() { panic(\"method\") }\n"
|
|
767
|
+
injected_auth = "X-Token\"\nfunc init() { panic(\"auth\") }\n"
|
|
768
|
+
|
|
769
|
+
malicious_description = {
|
|
770
|
+
authentication: {
|
|
771
|
+
oauth2: {
|
|
772
|
+
http_header: injected_auth,
|
|
773
|
+
revoke_url: "https://auth.example/revoke\"\nfunc init() { panic(\"revoke\") }\n"
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
meta: { namespace: "_meta#{injected_name}" },
|
|
777
|
+
resources: {
|
|
778
|
+
action_state: {
|
|
779
|
+
resources: {},
|
|
780
|
+
actions: {
|
|
781
|
+
show: action_description(
|
|
782
|
+
method: 'GET',
|
|
783
|
+
path: '/action_states/{action_state_id}',
|
|
784
|
+
output: {
|
|
785
|
+
layout: 'object',
|
|
786
|
+
namespace: 'action_state',
|
|
787
|
+
parameters: { id: { type: 'Integer' } }
|
|
788
|
+
}
|
|
789
|
+
),
|
|
790
|
+
poll: action_description(
|
|
791
|
+
method: 'GET',
|
|
792
|
+
path: '/action_states/{action_state_id}/poll',
|
|
793
|
+
output: {
|
|
794
|
+
layout: 'object',
|
|
795
|
+
namespace: 'action_state',
|
|
796
|
+
parameters: { finished: { type: 'Boolean' } }
|
|
797
|
+
}
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
victim: {
|
|
802
|
+
resources: {},
|
|
803
|
+
actions: {
|
|
804
|
+
list: action_description(method: 'GET', path: injected_path)
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
injected_name => {
|
|
808
|
+
resources: {},
|
|
809
|
+
actions: {
|
|
810
|
+
injected_name => action_description(
|
|
811
|
+
method: injected_method,
|
|
812
|
+
path: "/#{injected_name}",
|
|
813
|
+
aliases: [injected_name],
|
|
814
|
+
input: {
|
|
815
|
+
layout: 'object',
|
|
816
|
+
namespace: "input#{injected_name}",
|
|
817
|
+
parameters: {
|
|
818
|
+
injected_name => { type: 'String', nullable: true }
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
output: {
|
|
822
|
+
layout: 'object',
|
|
823
|
+
namespace: "output#{injected_name}",
|
|
824
|
+
parameters: {
|
|
825
|
+
injected_name => { type: 'String' }
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
meta: {
|
|
829
|
+
global: {
|
|
830
|
+
input: {
|
|
831
|
+
layout: 'object',
|
|
832
|
+
namespace: "meta#{injected_name}",
|
|
833
|
+
parameters: {
|
|
834
|
+
"meta#{injected_name}" => { type: 'String' }
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
Dir.mktmpdir('haveapi-go-client-injection-') do |dir|
|
|
846
|
+
communicator = instance_double(
|
|
847
|
+
HaveAPI::Client::Communicator,
|
|
848
|
+
describe_api: malicious_description
|
|
849
|
+
)
|
|
850
|
+
allow(HaveAPI::Client::Communicator).to receive(:new).and_return(communicator)
|
|
851
|
+
|
|
852
|
+
generator = described_class.new(
|
|
853
|
+
'http://unused.example',
|
|
854
|
+
dir,
|
|
855
|
+
module: 'example.com/haveapi-injection',
|
|
856
|
+
package: 'client'
|
|
857
|
+
)
|
|
858
|
+
generator.generate
|
|
859
|
+
generator.go_fmt
|
|
860
|
+
|
|
861
|
+
generated_sources = Dir[File.join(dir, 'client', '*.go')].map do |path|
|
|
862
|
+
File.read(path)
|
|
863
|
+
end.join("\n")
|
|
864
|
+
|
|
865
|
+
expect(generated_sources).not_to match(/^\s*func init\(\)/)
|
|
866
|
+
|
|
867
|
+
File.write(File.join(dir, 'client', 'package_load_test.go'), <<~GO)
|
|
868
|
+
package client
|
|
869
|
+
|
|
870
|
+
import "testing"
|
|
871
|
+
|
|
872
|
+
func TestGeneratedPackageLoads(t *testing.T) {}
|
|
873
|
+
GO
|
|
874
|
+
|
|
875
|
+
go_out, go_err, go_status = Open3.capture3(
|
|
876
|
+
{ 'CGO_ENABLED' => '0' },
|
|
877
|
+
'go',
|
|
878
|
+
'test',
|
|
879
|
+
'./...',
|
|
880
|
+
chdir: dir
|
|
881
|
+
)
|
|
230
882
|
expect(go_status).to be_success, "go test failed: #{go_out}\n#{go_err}"
|
|
231
883
|
end
|
|
232
884
|
end
|