@synopackageland/cli 0.14.1 → 0.15.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +26 -16
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -846
  5. package/dist/build-spk/resource-worker.d.ts +2 -14
  6. package/dist/build-spk/resource-worker.js +3 -166
  7. package/dist/cli.js +208 -56
  8. package/dist/compose/analyze.d.ts +3 -61
  9. package/dist/compose/analyze.js +35 -776
  10. package/dist/compose/env.d.ts +5 -3
  11. package/dist/compose/env.js +17 -25
  12. package/dist/compose/runner.js +23 -6
  13. package/dist/contract/web-launch.d.ts +1 -12
  14. package/dist/contract/web-launch.js +10 -74
  15. package/dist/ctl/cli.d.ts +36 -0
  16. package/dist/ctl/cli.js +218 -0
  17. package/dist/ctl/descriptor.d.ts +81 -0
  18. package/dist/ctl/descriptor.js +93 -0
  19. package/dist/ctl/dsm-auth.d.ts +10 -0
  20. package/dist/ctl/dsm-auth.js +30 -0
  21. package/dist/ctl/errors.d.ts +17 -0
  22. package/dist/ctl/errors.js +32 -0
  23. package/dist/ctl/flows.d.ts +11 -0
  24. package/dist/ctl/flows.js +52 -0
  25. package/dist/ctl/input.d.ts +20 -0
  26. package/dist/ctl/input.js +128 -0
  27. package/dist/ctl/json.d.ts +2 -0
  28. package/dist/ctl/json.js +38 -0
  29. package/dist/ctl/redaction.d.ts +15 -0
  30. package/dist/ctl/redaction.js +198 -0
  31. package/dist/ctl/service.d.ts +38 -0
  32. package/dist/ctl/service.js +156 -0
  33. package/dist/ctl/store.d.ts +51 -0
  34. package/dist/ctl/store.js +93 -0
  35. package/dist/ctl/transfers.d.ts +14 -0
  36. package/dist/ctl/transfers.js +74 -0
  37. package/dist/ctl/transport.d.ts +11 -0
  38. package/dist/ctl/transport.js +79 -0
  39. package/dist/deploy/deploy.d.ts +4 -3
  40. package/dist/deploy/deploy.js +5 -210
  41. package/dist/deploy/plan.js +26 -7
  42. package/dist/dev.js +5 -4
  43. package/dist/error-registry.js +1020 -834
  44. package/dist/info.js +120 -28
  45. package/dist/init.js +188 -64
  46. package/dist/inspect.js +21 -6
  47. package/dist/runtime/executor-digests.json +1 -12
  48. package/dist/skill.js +51 -9
  49. package/dist/standalone/api-description-inspect.d.ts +4 -0
  50. package/dist/standalone/api-description-inspect.js +12 -0
  51. package/dist/standalone/api-description.d.ts +7 -0
  52. package/dist/standalone/api-description.js +73 -0
  53. package/dist/standalone/build.d.ts +3 -0
  54. package/dist/standalone/build.js +140 -33
  55. package/dist/standalone/compose-errors.d.ts +3 -0
  56. package/dist/standalone/compose-errors.js +243 -0
  57. package/dist/standalone/compose.d.ts +97 -10
  58. package/dist/standalone/compose.js +306 -75
  59. package/dist/standalone/daemon.js +111 -29
  60. package/dist/standalone/dsm-window.js +39 -8
  61. package/dist/standalone/identity-gateway.d.ts +9 -2
  62. package/dist/standalone/identity-gateway.js +282 -66
  63. package/dist/standalone/inspect-spk.d.ts +12 -0
  64. package/dist/standalone/inspect-spk.js +666 -136
  65. package/dist/standalone/lifecycle.js +124 -42
  66. package/dist/standalone/manifest.d.ts +8 -1
  67. package/dist/standalone/manifest.js +303 -86
  68. package/dist/standalone/resource.js +6 -2
  69. package/dist/standalone/spk.d.ts +4 -1
  70. package/dist/standalone/spk.js +138 -39
  71. package/dist/template-paths.d.ts +0 -2
  72. package/dist/template-paths.js +0 -5
  73. package/dist/test-command.d.ts +1 -1
  74. package/dist/test-command.js +1 -1
  75. package/dist/validate.d.ts +3 -3
  76. package/dist/validate.js +32 -140
  77. package/package.json +5 -3
  78. package/schema/synopkgland-api-v1.schema.json +423 -0
  79. package/skills/SKILL.md +3 -1
  80. package/skills/reference/compose.md +349 -0
  81. package/skills/reference/create.md +8 -17
  82. package/skills/reference/ctl.md +41 -0
  83. package/skills/reference/deploy.md +1 -1
  84. package/skills/reference/files.md +4 -0
  85. package/skills/reference/identity-gateway.md +8 -144
  86. package/skills/reference/wrap.md +10 -39
  87. package/assets/go/daemon/fixture_linux_test.go +0 -15
  88. package/assets/go/daemon/fixture_other_test.go +0 -7
  89. package/assets/go/daemon/go.mod +0 -5
  90. package/assets/go/daemon/main.go +0 -636
  91. package/assets/go/daemon/main_test.go +0 -315
  92. package/assets/go/daemon/network.go +0 -107
  93. package/assets/go/daemon/network_test.go +0 -86
  94. package/assets/go/daemon/process_linux.go +0 -55
  95. package/assets/go/daemon/process_linux_test.go +0 -161
  96. package/assets/go/daemon/process_other.go +0 -14
  97. package/assets/go/daemon/testdata/child.go +0 -44
  98. package/assets/go/gateway/auth.go +0 -101
  99. package/assets/go/gateway/auth_test.go +0 -216
  100. package/assets/go/gateway/cgi.go +0 -133
  101. package/assets/go/gateway/cgi_test.go +0 -156
  102. package/assets/go/gateway/csrf.go +0 -73
  103. package/assets/go/gateway/csrf_test.go +0 -155
  104. package/assets/go/gateway/directory.go +0 -108
  105. package/assets/go/gateway/directory_test.go +0 -202
  106. package/assets/go/gateway/gateway.go +0 -412
  107. package/assets/go/gateway/gateway_test.go +0 -676
  108. package/assets/go/gateway/go.mod +0 -5
  109. package/assets/go/gateway/groups.go +0 -66
  110. package/assets/go/gateway/main.go +0 -45
  111. package/dist/build-spk/gateway.d.ts +0 -31
  112. package/dist/build-spk/gateway.js +0 -60
  113. package/dist/compose/privilege.d.ts +0 -14
  114. package/dist/compose/privilege.js +0 -102
@@ -1,156 +0,0 @@
1
- package main
2
-
3
- import (
4
- "bufio"
5
- "bytes"
6
- "io"
7
- "net/http"
8
- "net/http/httptest"
9
- "strings"
10
- "testing"
11
- )
12
-
13
- func cgiRequest(t *testing.T) *http.Request {
14
- t.Helper()
15
- return httptest.NewRequest("GET", "/webman/3rdparty/App/cgi/gateway.cgi/", nil)
16
- }
17
-
18
- func TestCGIReplyIsAStatusLineThenHeadersThenBody(t *testing.T) {
19
- var out bytes.Buffer
20
- err := serveCGI(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21
- w.Header().Set("Content-Type", "text/plain")
22
- w.Header().Set("X-Thing", "1")
23
- w.WriteHeader(404)
24
- io.WriteString(w, "nope")
25
- }), cgiRequest(t), strings.NewReader(""), &out)
26
- if err != nil {
27
- t.Fatal(err)
28
- }
29
- if want := "Status: 404 Not Found\r\nContent-Type: text/plain\r\nX-Thing: 1\r\n\r\nnope"; out.String() != want {
30
- t.Fatalf("got %q\nwant %q", out.String(), want)
31
- }
32
- }
33
-
34
- func TestCGIReplyWithoutAWriteStillCarriesAStatus(t *testing.T) {
35
- var out bytes.Buffer
36
- if err := serveCGI(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), cgiRequest(t), strings.NewReader(""), &out); err != nil {
37
- t.Fatal(err)
38
- }
39
- if !strings.HasPrefix(out.String(), "Status: 200 OK\r\n") || !strings.HasSuffix(out.String(), "\r\n\r\n") {
40
- t.Fatalf("got %q", out.String())
41
- }
42
- }
43
-
44
- func TestCGIReplySniffsAMissingContentType(t *testing.T) {
45
- var out bytes.Buffer
46
- serveCGI(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "<html></html>") }), cgiRequest(t), strings.NewReader(""), &out)
47
- if !strings.Contains(out.String(), "Content-Type: text/html; charset=utf-8\r\n") {
48
- t.Fatalf("got %q", out.String())
49
- }
50
- }
51
-
52
- // flushRecorder notes how many bytes were out when the handler was still running.
53
- type flushRecorder struct {
54
- bytes.Buffer
55
- atFlush int
56
- }
57
-
58
- func TestCGIFlushSendsWhatIsBufferedAtOnce(t *testing.T) {
59
- var out flushRecorder
60
- serveCGI(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61
- w.Header().Set("Content-Type", "text/event-stream")
62
- io.WriteString(w, "data: one\n\n")
63
- w.(http.Flusher).Flush()
64
- out.atFlush = out.Len()
65
- io.WriteString(w, "data: two\n\n")
66
- }), cgiRequest(t), strings.NewReader(""), &out)
67
- if !strings.HasSuffix(out.String(), "data: one\n\ndata: two\n\n") || out.atFlush < len("data: one\n\n") {
68
- t.Fatalf("flushed %d bytes before the handler ended; total %q", out.atFlush, out.String())
69
- }
70
- }
71
-
72
- // After a hijack stdin and stdout belong to the handler. What it writes first is an HTTP/1.1 status line;
73
- // DSM's CGI interface wants "Status:" instead.
74
- func TestCGIHijackTurnsTheStatusLineIntoACGIHeaderAndPassesBytesBothWays(t *testing.T) {
75
- in, inW := io.Pipe()
76
- outR, out := io.Pipe()
77
- done := make(chan error, 1)
78
- go func() {
79
- done <- serveCGI(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80
- conn, brw, err := w.(http.Hijacker).Hijack()
81
- if err != nil {
82
- t.Error(err)
83
- return
84
- }
85
- defer conn.Close()
86
- io.WriteString(brw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
87
- brw.Flush()
88
- buf := make([]byte, 16)
89
- n, _ := brw.Read(buf)
90
- io.WriteString(conn, "got:"+string(buf[:n]))
91
- }), cgiRequest(t), in, out)
92
- out.Close()
93
- }()
94
- r := bufio.NewReader(outR)
95
- for _, want := range []string{"Status: 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", ""} {
96
- if line, _ := r.ReadString('\n'); strings.TrimRight(line, "\r\n") != want {
97
- t.Fatalf("header line %q, want %q", line, want)
98
- }
99
- }
100
- io.WriteString(inW, "ping")
101
- rest, _ := io.ReadAll(r)
102
- if string(rest) != "got:ping" {
103
- t.Fatalf("after the header block: %q (nothing, not even a second status, may follow the handler's bytes)", rest)
104
- }
105
- if err := <-done; err != nil {
106
- t.Fatal(err)
107
- }
108
- }
109
-
110
- func TestCGIReplyCannotBeWrittenAfterAHijack(t *testing.T) {
111
- var out bytes.Buffer
112
- serveCGI(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
113
- if _, _, err := w.(http.Hijacker).Hijack(); err != nil {
114
- t.Fatal(err)
115
- }
116
- if _, err := w.Write([]byte("late")); err == nil {
117
- t.Error("Write after Hijack succeeded")
118
- }
119
- }), cgiRequest(t), strings.NewReader(""), &out)
120
- if out.Len() != 0 {
121
- t.Fatalf("output after a bare hijack: %q", out.String())
122
- }
123
- }
124
-
125
- // The whole chain as DSM runs it: a WebSocket upgrade enters through the CGI environment and the container's
126
- // bytes come back on stdout.
127
- func TestWebSocketThroughTheCGIEnvironment(t *testing.T) {
128
- up := newUpstream(t, wsEchoHandler)
129
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
130
- req := httptest.NewRequest("GET", base+"/ws", nil)
131
- req.Host = "nas.example:5001"
132
- req.Header.Set("Cookie", "id=abc")
133
- req.Header.Set("Connection", "Upgrade")
134
- req.Header.Set("Upgrade", "websocket")
135
- req.Header.Set("Origin", "https://nas.example:5001")
136
- in, inW := io.Pipe()
137
- outR, out := io.Pipe()
138
- done := make(chan error, 1)
139
- go func() { done <- serveCGI(g, req, in, out); out.Close() }()
140
- r := bufio.NewReader(outR)
141
- if line, _ := r.ReadString('\n'); strings.TrimSpace(line) != "Status: 101 Switching Protocols" {
142
- t.Fatalf("first line %q", line)
143
- }
144
- for line, _ := r.ReadString('\n'); strings.TrimSpace(line) != ""; line, _ = r.ReadString('\n') {
145
- }
146
- io.WriteString(inW, "hello")
147
- got := make([]byte, len("echo:hello"))
148
- if _, err := io.ReadFull(r, got); err != nil || string(got) != "echo:hello" {
149
- t.Fatalf("echo = %q, %v", got, err)
150
- }
151
- inW.Close() // DSM closes stdin when the browser goes away
152
- io.Copy(io.Discard, r)
153
- if err := <-done; err != nil {
154
- t.Fatal(err)
155
- }
156
- }
@@ -1,73 +0,0 @@
1
- package main
2
-
3
- // CSRF defence for state-changing requests. DSM issues a SynoToken only when
4
- // "Enhance CSRF protection" (enable_syno_token in synoinfo.conf) is on; when it
5
- // is off, DSM hands every session the same placeholder and validates nothing,
6
- // so the token alone proves nothing. The gateway therefore needs evidence that
7
- // the browser itself made the request from the gateway's own origin, and it
8
- // never stores or trusts a token value of its own.
9
-
10
- import (
11
- "net/http"
12
- "net/url"
13
- "strings"
14
- )
15
-
16
- const maxTokenLen = 256
17
-
18
- // wellFormedToken accepts any printable ASCII value of sane length. Whether the
19
- // value is the right one is DSM's call (authenticate.cgi gets it on every
20
- // request); the gateway only refuses requests that carry nothing usable.
21
- func wellFormedToken(token string) bool {
22
- if token == "" || len(token) > maxTokenLen {
23
- return false
24
- }
25
- for i := 0; i < len(token); i++ {
26
- if token[i] < 0x20 || token[i] > 0x7e {
27
- return false
28
- }
29
- }
30
- return true
31
- }
32
-
33
- // sameOrigin reports whether the browser says the request came from the page's
34
- // own origin. Sec-Fetch-Site, when present, must say same-origin; Origin, when
35
- // present, must be the request's own host. At least one must be present: a
36
- // browser sends Origin on every cross-origin and every non-GET request, so a
37
- // state-changing request with neither is not from a browser that can be trusted
38
- // to scope it (browsers without either, such as IE 11, are refused).
39
- func sameOrigin(r *http.Request) bool {
40
- site := r.Header.Get("Sec-Fetch-Site")
41
- origin := r.Header.Get("Origin")
42
- if site == "" && origin == "" {
43
- return false
44
- }
45
- if site != "" && site != "same-origin" {
46
- return false
47
- }
48
- return origin == "" || originMatchesHost(origin, r.Host)
49
- }
50
-
51
- // originMatchesHost compares an Origin header (scheme://host[:port], no path)
52
- // with the Host the request was addressed to. The scheme only decides which
53
- // port is the default, because a CGI does not reliably learn whether the
54
- // connection was TLS.
55
- func originMatchesHost(origin, host string) bool {
56
- u, err := url.Parse(origin)
57
- if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || (u.Path != "" && u.Path != "/") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
58
- return false
59
- }
60
- return withDefaultPort(u.Host, u.Scheme) == withDefaultPort(host, u.Scheme)
61
- }
62
-
63
- func withDefaultPort(host, scheme string) string {
64
- host = strings.ToLower(host)
65
- i := strings.LastIndex(host, ":")
66
- if i >= 0 && !strings.Contains(host[i:], "]") {
67
- return host
68
- }
69
- if scheme == "https" {
70
- return host + ":443"
71
- }
72
- return host + ":80"
73
- }
@@ -1,155 +0,0 @@
1
- package main
2
-
3
- import (
4
- "net/http"
5
- "net/http/httptest"
6
- "strings"
7
- "testing"
8
- )
9
-
10
- // browserPost is what a browser sends for a same-origin fetch to the gateway
11
- // (httptest requests are addressed to example.com).
12
- func browserPost(token string) map[string]string {
13
- return map[string]string{
14
- "Cookie": "id=abc",
15
- "X-SYNO-TOKEN": token,
16
- "Origin": "http://example.com",
17
- "Sec-Fetch-Site": "same-origin",
18
- }
19
- }
20
-
21
- func with(hdr map[string]string, kv ...string) map[string]string {
22
- out := map[string]string{}
23
- for k, v := range hdr {
24
- out[k] = v
25
- }
26
- for i := 0; i < len(kv); i += 2 {
27
- if kv[i+1] == "" {
28
- delete(out, kv[i])
29
- } else {
30
- out[kv[i]] = kv[i+1]
31
- }
32
- }
33
- return out
34
- }
35
-
36
- func csrfGateway(t *testing.T) (http.Handler, *upstream) {
37
- t.Helper()
38
- up := newUpstream(t, hello)
39
- return newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up), up
40
- }
41
-
42
- func TestSameOriginStateChangeReachesContainer(t *testing.T) {
43
- g, up := csrfGateway(t)
44
- cases := map[string]map[string]string{
45
- "origin and fetch metadata": browserPost("tok"),
46
- "origin only (no fetch metadata, e.g. Safari < 16.4)": with(browserPost("tok"), "Sec-Fetch-Site", ""),
47
- "fetch metadata only": with(browserPost("tok"), "Origin", ""),
48
- "host case and default port": with(browserPost("tok"), "Origin", "http://EXAMPLE.com:80"),
49
- }
50
- for name, hdr := range cases {
51
- if w := do(g, "POST", base+"/save", hdr, "x"); w.Code != 200 {
52
- t.Errorf("%s: %d %q", name, w.Code, w.Body.String())
53
- }
54
- }
55
- if up.hits.Load() != int32(len(cases)) {
56
- t.Fatalf("hits=%d", up.hits.Load())
57
- }
58
- }
59
-
60
- func TestCrossSiteStateChangeIsRefusedBeforeTheContainer(t *testing.T) {
61
- g, up := csrfGateway(t)
62
- cases := map[string]map[string]string{
63
- "foreign origin": with(browserPost("tok"), "Origin", "https://evil.example", "Sec-Fetch-Site", ""),
64
- "foreign origin, claims same": with(browserPost("tok"), "Origin", "https://evil.example"),
65
- "origin null": with(browserPost("tok"), "Origin", "null", "Sec-Fetch-Site", ""),
66
- "origin is not a URL": with(browserPost("tok"), "Origin", "::::", "Sec-Fetch-Site", ""),
67
- "origin has a path": with(browserPost("tok"), "Origin", "http://example.com/x", "Sec-Fetch-Site", ""),
68
- "same host other port": with(browserPost("tok"), "Origin", "http://example.com:5001", "Sec-Fetch-Site", ""),
69
- "sub-domain of the host": with(browserPost("tok"), "Origin", "http://evil.example.com", "Sec-Fetch-Site", ""),
70
- "fetch metadata cross-site": with(browserPost("tok"), "Sec-Fetch-Site", "cross-site"),
71
- "fetch metadata same-site": with(browserPost("tok"), "Sec-Fetch-Site", "same-site"),
72
- "fetch metadata none": with(browserPost("tok"), "Sec-Fetch-Site", "none"),
73
- "fetch metadata unknown": with(browserPost("tok"), "Sec-Fetch-Site", "whatever"),
74
- "fetch cross-site, origin same": with(browserPost("tok"), "Sec-Fetch-Site", "cross-site", "Origin", "http://example.com"),
75
- }
76
- for name, hdr := range cases {
77
- w := do(g, "POST", base+"/save", hdr, "x")
78
- if !refusedAsForbidden(w, "cross_site_blocked") {
79
- t.Errorf("%s: %d %q", name, w.Code, w.Body.String())
80
- }
81
- }
82
- if up.hits.Load() != 0 {
83
- t.Fatalf("hits=%d", up.hits.Load())
84
- }
85
- }
86
-
87
- func TestStateChangeWithoutAnyOriginEvidenceIsRefused(t *testing.T) {
88
- g, up := csrfGateway(t)
89
- hdr := with(browserPost("tok"), "Origin", "", "Sec-Fetch-Site", "")
90
- w := do(g, "POST", base+"/save", hdr, "x")
91
- if !refusedAsForbidden(w, "cross_site_blocked") || up.hits.Load() != 0 {
92
- t.Fatalf("%d %q hits=%d", w.Code, w.Body.String(), up.hits.Load())
93
- }
94
- }
95
-
96
- func TestStateChangeNeedsAWellFormedToken(t *testing.T) {
97
- g, up := csrfGateway(t)
98
- for name, token := range map[string]string{
99
- "missing": "",
100
- "too long": strings.Repeat("a", 257),
101
- "control bytes": "to\x01ken",
102
- "non ASCII": "tök",
103
- } {
104
- hdr := with(browserPost("tok"), "X-SYNO-TOKEN", "")
105
- if token != "" {
106
- hdr["X-SYNO-TOKEN"] = token
107
- }
108
- w := do(g, "POST", base+"/save", hdr, "x")
109
- if !refusedAsForbidden(w, "token_required") {
110
- t.Errorf("%s: %d %q", name, w.Code, w.Body.String())
111
- }
112
- }
113
- if up.hits.Load() != 0 {
114
- t.Fatalf("hits=%d", up.hits.Load())
115
- }
116
- }
117
-
118
- func TestSafeRequestsIgnoreOriginEvidence(t *testing.T) {
119
- g, _ := csrfGateway(t)
120
- hdr := map[string]string{"Cookie": "id=abc", "Origin": "https://evil.example", "Sec-Fetch-Site": "cross-site"}
121
- for _, method := range []string{"GET", "HEAD"} {
122
- if w := do(g, method, base+"/", hdr, ""); w.Code != 200 {
123
- t.Errorf("%s: %d", method, w.Code)
124
- }
125
- }
126
- }
127
-
128
- func TestOriginMatchIgnoresDefaultPortsOnly(t *testing.T) {
129
- cases := []struct {
130
- origin, host string
131
- want bool
132
- }{
133
- {"http://nas.local:5000", "nas.local:5000", true},
134
- {"https://nas.local:5001", "nas.local:5001", true},
135
- {"https://nas.local", "nas.local:443", true},
136
- {"http://nas.local", "nas.local", true},
137
- {"https://nas.local:5001", "nas.local:5000", false},
138
- {"http://[::1]:5000", "[::1]:5000", true},
139
- {"http://nas.local", "nas.local:5000", false},
140
- {"", "nas.local", false},
141
- }
142
- for _, c := range cases {
143
- if got := originMatchesHost(c.origin, c.host); got != c.want {
144
- t.Errorf("originMatchesHost(%q, %q) = %v", c.origin, c.host, got)
145
- }
146
- }
147
- }
148
-
149
- // refusedAsForbidden: the gateway means 403, but DSM would replace a 403 body, so it sends 401
150
- // with the intended status and the reason in headers.
151
- func refusedAsForbidden(w *httptest.ResponseRecorder, code string) bool {
152
- h := w.Header()
153
- return w.Code == http.StatusUnauthorized && h.Get("X-Synopkgland-Status") == "403" &&
154
- h.Get("X-Synopkgland-Error") == code && strings.Contains(w.Body.String(), code)
155
- }
@@ -1,108 +0,0 @@
1
- package main
2
-
3
- import (
4
- "context"
5
- "errors"
6
- "fmt"
7
- "os/exec"
8
- "regexp"
9
- "strconv"
10
- "strings"
11
- "time"
12
- )
13
-
14
- const maxDirectoryOutput = 64 << 10
15
- const maxDirectoryGroups = 256
16
-
17
- // DSM's dynamic /usr/bin/id uses libc NSS; os/user in the static CGI does not.
18
- // A fixed executable, a single validated username argument and a clean locale
19
- // environment keep the browser's credentials and process environment out of NSS.
20
- // No SDK cache: query once per authenticated request, with a two-second limit.
21
- type directoryLookup struct {
22
- timeout time.Duration
23
- run func(context.Context, string, []string, []string) ([]byte, error)
24
- }
25
-
26
- func (d directoryLookup) groups(ctx context.Context, user string) ([]string, error) {
27
- if !userName.MatchString(user) || strings.HasPrefix(user, "-") {
28
- return nil, errors.New("invalid directory account")
29
- }
30
- ctx, cancel := context.WithTimeout(ctx, d.timeout)
31
- defer cancel()
32
- out, err := d.run(ctx, "/usr/bin/id", []string{user}, []string{"LC_ALL=C"})
33
- if ctx.Err() != nil {
34
- return nil, fmt.Errorf("directory groups: %w", ctx.Err())
35
- }
36
- if err != nil {
37
- return nil, errors.New("directory group lookup failed")
38
- }
39
- return parseDirectoryGroups(out)
40
- }
41
-
42
- // Bounded stdout also bounds memory if a directory returns an excessive list.
43
- // Never include command output in errors: it is not a diagnostic credential path.
44
- type directoryOutput struct{ data []byte }
45
-
46
- func (b *directoryOutput) Write(p []byte) (int, error) {
47
- if len(p) > maxDirectoryOutput-len(b.data) {
48
- return 0, errors.New("directory output exceeds limit")
49
- }
50
- b.data = append(b.data, p...)
51
- return len(p), nil
52
- }
53
- func runDirectoryCommand(ctx context.Context, path string, args, env []string) ([]byte, error) {
54
- cmd := exec.CommandContext(ctx, path, args...)
55
- cmd.Env = env
56
- cmd.WaitDelay = 100 * time.Millisecond
57
- var out directoryOutput
58
- cmd.Stdout = &out
59
- err := cmd.Run()
60
- return out.data, err
61
- }
62
-
63
- var directoryIDLine = regexp.MustCompile(`^uid=([0-9]+)\(([^()\r\n]+)\) gid=([0-9]+)\(([^()\r\n]+)\) groups=([^\r\n]+)\n?$`)
64
- var directoryGroupEntry = regexp.MustCompile(`^([0-9]+)\(([^()\r\n]+)\)$`)
65
-
66
- // Parse full id output, not id -Gn: the latter loses boundaries when AD group
67
- // names contain spaces (for example VT-NAS\Domain Users). Require every gid to
68
- // resolve to a bounded valid name, and require the primary group in the list.
69
- // Unresolved numeric gids, partial/malformed output and ambiguous delimiters deny.
70
- func parseDirectoryGroups(out []byte) ([]string, error) {
71
- invalid := errors.New("incomplete or invalid directory groups")
72
- if len(out) > maxDirectoryOutput {
73
- return nil, invalid
74
- }
75
- m := directoryIDLine.FindStringSubmatch(string(out))
76
- if m == nil || !validDirectoryID(m[1]) || !validDirectoryID(m[3]) || !userName.MatchString(m[2]) || !groupName.MatchString(m[4]) {
77
- return nil, invalid
78
- }
79
- entries := strings.Split(m[5], ",")
80
- if len(entries) > maxDirectoryGroups {
81
- return nil, invalid
82
- }
83
- groups := make([]string, 0, len(entries))
84
- seen := map[string]bool{}
85
- primary := false
86
- for _, entry := range entries {
87
- g := directoryGroupEntry.FindStringSubmatch(entry)
88
- if g == nil || !validDirectoryID(g[1]) || !groupName.MatchString(g[2]) || seen[g[1]] {
89
- return nil, invalid
90
- }
91
- seen[g[1]] = true
92
- if g[1] == m[3] {
93
- if g[2] != m[4] {
94
- return nil, invalid
95
- }
96
- primary = true
97
- }
98
- groups = append(groups, g[2])
99
- }
100
- if !primary {
101
- return nil, invalid
102
- }
103
- return groups, nil
104
- }
105
- func validDirectoryID(value string) bool {
106
- n, err := strconv.ParseUint(value, 10, 32)
107
- return err == nil && n < 1<<32-1 && strconv.FormatUint(n, 10) == value
108
- }
@@ -1,202 +0,0 @@
1
- package main
2
-
3
- import (
4
- "context"
5
- "errors"
6
- "net/http/httptest"
7
- "os"
8
- "slices"
9
- "strings"
10
- "testing"
11
- "time"
12
- )
13
-
14
- const directoryID = `uid=2969567732(VT-NAS\Administrator) gid=2969567745(VT-NAS\Domain Users) groups=2969567745(VT-NAS\Domain Users),2969567751(VT-NAS\Enterprise Admins)`
15
-
16
- func TestDirectoryIdentityUsesNSSIncludingPrimaryAndSupplementaryGroups(t *testing.T) {
17
- a := newTestAuth(t, &fakeRunner{out: `VT-NAS\Administrator`})
18
- var gotUser string
19
- a.directoryGroups = func(ctx context.Context, user string) ([]string, error) {
20
- gotUser = user
21
- if _, ok := ctx.Deadline(); !ok {
22
- t.Fatal("missing deadline")
23
- }
24
- return parseDirectoryGroups([]byte(directoryID))
25
- }
26
- id, err := a.Authenticate(sessionRequest())
27
- if err != nil || gotUser != `VT-NAS\Administrator` || !slices.Equal(id.Groups, []string{`VT-NAS\Domain Users`, `VT-NAS\Enterprise Admins`}) {
28
- t.Fatalf("id=%+v user=%q err=%v", id, gotUser, err)
29
- }
30
- }
31
-
32
- func TestLocalIdentityNeverNeedsDirectoryLookup(t *testing.T) {
33
- a := newTestAuth(t, &fakeRunner{out: "alice"})
34
- a.directoryGroups = func(context.Context, string) ([]string, error) {
35
- t.Fatal("directory lookup for local account")
36
- return nil, nil
37
- }
38
- id, err := a.Authenticate(sessionRequest())
39
- if err != nil || !slices.Contains(id.Groups, "staff") {
40
- t.Fatalf("id=%+v err=%v", id, err)
41
- }
42
- }
43
-
44
- func TestDirectoryFailureCannotFallBackToLocalMemberLists(t *testing.T) {
45
- a := newTestAuth(t, &fakeRunner{out: `OTHER\alice`})
46
- if err := os.WriteFile(a.groupFile, []byte("staff:x:1030:OTHER\\alice\n"), 0o644); err != nil {
47
- t.Fatal(err)
48
- }
49
- a.directoryGroups = func(context.Context, string) ([]string, error) { return nil, errors.New("directory unavailable") }
50
- if _, err := a.Authenticate(sessionRequest()); err == nil || errors.Is(err, errNoSession) {
51
- t.Fatalf("err=%v", err)
52
- }
53
- }
54
-
55
- func TestDirectoryMembershipAndSessionAreRecheckedEachRequest(t *testing.T) {
56
- runner := &fakeRunner{out: `VT-NAS\Administrator`}
57
- a := newTestAuth(t, runner)
58
- calls := 0
59
- a.directoryGroups = func(context.Context, string) ([]string, error) {
60
- calls++
61
- if calls == 1 {
62
- return []string{`VT-NAS\Domain Users`}, nil
63
- }
64
- return []string{`VT-NAS\Other`}, nil
65
- }
66
- id, _ := a.Authenticate(sessionRequest())
67
- if !slices.Contains(id.Groups, `VT-NAS\Domain Users`) {
68
- t.Fatal(id)
69
- }
70
- id, _ = a.Authenticate(sessionRequest())
71
- if slices.Contains(id.Groups, `VT-NAS\Domain Users`) || calls != 2 {
72
- t.Fatal(id, calls)
73
- }
74
- runner.err = &exitError{}
75
- if _, err := a.Authenticate(sessionRequest()); !errors.Is(err, errNoSession) || calls != 2 {
76
- t.Fatal(err, calls)
77
- }
78
- }
79
-
80
- func TestNSSParserPreservesQualifiedNamesAndFailsClosed(t *testing.T) {
81
- for _, text := range []string{directoryID + "\n", `uid=123(alice@example.test) gid=200(staff@example.test) groups=200(staff@example.test),201(Domain Users@example.test)`} {
82
- groups, err := parseDirectoryGroups([]byte(text))
83
- if err != nil || len(groups) != 2 {
84
- t.Fatalf("groups=%q err=%v", groups, err)
85
- }
86
- }
87
- for _, text := range []string{"", "100 101", `uid=1(alice) gid=2(staff) groups=2`, `uid=1(alice) gid=2(staff) groups=3(ops)`, `uid=1(alice) gid=2(staff) groups=2(staff),3`, `uid=1(alice) gid=2(staff) groups=2(staff),3(bad(name))`, directoryID + "\n" + directoryID, `uid=1(alice) gid=2(staff) groups=2(staff),2(ops)`} {
88
- if _, err := parseDirectoryGroups([]byte(text)); err == nil {
89
- t.Fatalf("accepted %q", text)
90
- }
91
- }
92
- }
93
-
94
- func TestDirectoryCommandIsBoundedAndHasNoSessionEnvironment(t *testing.T) {
95
- var gotPath string
96
- var gotArgs, gotEnv []string
97
- lookup := directoryLookup{timeout: 20 * time.Millisecond, run: func(ctx context.Context, path string, args, env []string) ([]byte, error) {
98
- gotPath, gotArgs, gotEnv = path, args, env
99
- if _, ok := ctx.Deadline(); !ok {
100
- t.Fatal("missing deadline")
101
- }
102
- return []byte(directoryID), nil
103
- }}
104
- if _, err := lookup.groups(context.Background(), `VT-NAS\Administrator`); err != nil {
105
- t.Fatal(err)
106
- }
107
- if gotPath != "/usr/bin/id" || !slices.Equal(gotArgs, []string{`VT-NAS\Administrator`}) || !slices.Equal(gotEnv, []string{"LC_ALL=C"}) {
108
- t.Fatal(gotPath, gotArgs, gotEnv)
109
- }
110
- for _, user := range []string{"", "-G", "--help", "alice\nadmin", "alice;id"} {
111
- gotPath = ""
112
- if _, err := lookup.groups(context.Background(), user); err == nil || gotPath != "" {
113
- t.Fatal(user, err, gotPath)
114
- }
115
- }
116
- lookup.run = func(ctx context.Context, _ string, _, _ []string) ([]byte, error) {
117
- <-ctx.Done()
118
- return nil, ctx.Err()
119
- }
120
- if _, err := lookup.groups(context.Background(), "alice"); !errors.Is(err, context.DeadlineExceeded) {
121
- t.Fatal(err)
122
- }
123
- }
124
-
125
- func TestDirectoryRulesKeepTheDomainAndExactCase(t *testing.T) {
126
- for _, name := range []string{`VT-NAS\Domain Users`, `Domain Users@example.test`, `DOMAIN\staff`, `staff@example.test`} {
127
- cfg := config{Upstream: "127.0.0.1:1", Access: accessGroups, Groups: []string{name}}
128
- g, err := newGateway(cfg, &fakeAuth{})
129
- if err != nil {
130
- t.Fatal(err)
131
- }
132
- if !g.permits(identity{Groups: []string{name}}) {
133
- t.Fatal(name)
134
- }
135
- for _, other := range []string{"Domain Users", "staff", `OTHER\Domain Users`, `VT-NAS\domain users`} {
136
- if other != name && g.permits(identity{Groups: []string{other}}) {
137
- t.Fatal(name, other)
138
- }
139
- }
140
- }
141
- g := &gateway{cfg: config{Access: accessAdmins}}
142
- if g.permits(identity{Groups: []string{`DOMAIN\administrators`}}) {
143
- t.Fatal("domain group became a local administrator")
144
- }
145
- }
146
-
147
- func TestDirectoryLookupFailureNeverReachesUpstream(t *testing.T) {
148
- a := newTestAuth(t, &fakeRunner{out: `DOMAIN\alice`})
149
- a.directoryGroups = func(context.Context, string) ([]string, error) { return nil, errors.New("offline") }
150
- g, err := newGateway(config{Upstream: "127.0.0.1:1", Access: accessGroups, Groups: []string{`DOMAIN\staff`}}, a)
151
- if err != nil {
152
- t.Fatal(err)
153
- }
154
- w := httptest.NewRecorder()
155
- g.ServeHTTP(w, sessionRequest())
156
- if w.Header().Get("X-Synopkgland-Error") != "auth_unavailable" {
157
- t.Fatal(w.Code, w.Body.String())
158
- }
159
- }
160
-
161
- func TestDirectoryIdentitySupportsQualifiedAccountLength(t *testing.T) {
162
- user := "DOMAIN\\" + strings.Repeat("a", 100)
163
- a := newTestAuth(t, &fakeRunner{out: user})
164
- a.directoryGroups = func(context.Context, string) ([]string, error) { return []string{`DOMAIN\staff`}, nil }
165
- id, err := a.Authenticate(sessionRequest())
166
- if err != nil || id.User != user {
167
- t.Fatal(id, err)
168
- }
169
- }
170
-
171
- func TestNSSParserBoundsIDsGroupsAndOutput(t *testing.T) {
172
- base := `uid=1(alice) gid=2(staff) groups=2(staff)`
173
- for _, text := range []string{strings.Replace(base, "uid=1", "uid=4294967295", 1), strings.Replace(base, "gid=2", "gid=4294967296", 1), base + strings.Repeat(",3(ops)", 256), base + strings.Repeat(" ", maxDirectoryOutput), strings.Replace(base, "2(staff)", "02(staff)", -1)} {
174
- if _, err := parseDirectoryGroups([]byte(text)); err == nil {
175
- t.Fatal("accepted out-of-bounds output")
176
- }
177
- }
178
- var output directoryOutput
179
- if _, err := output.Write(make([]byte, maxDirectoryOutput)); err != nil {
180
- t.Fatal(err)
181
- }
182
- if _, err := output.Write([]byte{1}); err == nil || len(output.data) != maxDirectoryOutput {
183
- t.Fatal("output not bounded")
184
- }
185
- }
186
-
187
- func TestNSSNonzeroExitAndCancellationDiscardEvenValidGroups(t *testing.T) {
188
- for _, cancelled := range []bool{false, true} {
189
- ctx, cancel := context.WithCancel(context.Background())
190
- defer cancel()
191
- d := directoryLookup{timeout: time.Second, run: func(context.Context, string, []string, []string) ([]byte, error) {
192
- if cancelled {
193
- cancel()
194
- return []byte(directoryID), nil
195
- }
196
- return []byte(directoryID), errors.New("exit 1")
197
- }}
198
- if groups, err := d.groups(ctx, `VT-NAS\Administrator`); err == nil || len(groups) != 0 {
199
- t.Fatal(groups, err)
200
- }
201
- }
202
- }