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.
@@ -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 "testing"
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('go', 'test', './...', chdir: dir)
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