Skip to content
This repository was archived by the owner on Jul 19, 2026. It is now read-only.

Commit eedc721

Browse files
authored
Harden browser connector validation (#62)
1 parent 4946db1 commit eedc721

7 files changed

Lines changed: 389 additions & 36 deletions

File tree

README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,20 @@ The Vault DB connector brokers short-lived Postgres credentials through HashiCor
124124

125125
The browser connector returns wrapped credential artifacts with explicit field-level fill instructions.
126126

127-
- validates exact origin URLs
128-
- requires a selector map per origin (CSS selectors for each credential field)
127+
- validates exact origin URLs and requires `https://` by default
128+
- allows `http://localhost[:port]` only when `ASB_BROWSER_ALLOW_INSECURE_LOCALHOST=true`
129+
- requires an explicit selector map per origin (CSS selectors for each credential field)
130+
- validates selector syntax at startup so malformed CSS fails before the broker serves traffic
129131
- unwrap returns explicit field selector/value pairs only — the broker never returns a general browser credential blob
130132
- single-use unwrap enforced at the artifact level
131133
- never auto-submits
132134

135+
Browser extension/runtime contract for the current relay flow:
136+
137+
- the browser runtime registers a relay session with tenant, public key, origin, tab id, and selectors
138+
- ASB returns a relay session id plus expiry metadata
139+
- artifact unwrap returns only explicit selector/value pairs for the approved origin and tab, never a raw reusable credential bundle
140+
133141
### Connector resolution
134142

135143
A static resolver routes capability and resource kind to the appropriate connector. Each connector validates its own resource descriptors independently.
@@ -271,11 +279,12 @@ Configure at least one attestation verifier:
271279
| `ASB_VAULT_ROLE` | Vault DB role name |
272280
| `ASB_VAULT_ALLOWED_ROLE_SUFFIXES` | Comma-separated allowed Vault DB role suffixes (default `_ro`) |
273281
| `ASB_VAULT_DSN_TEMPLATE` | DSN template for rendered credentials |
274-
| `ASB_BROWSER_ORIGIN` | Allowed browser origin (demo) |
282+
| `ASB_BROWSER_ORIGIN` | Allowed browser origin (demo); must use `https://` unless localhost override is enabled |
275283
| `ASB_BROWSER_USERNAME` | Browser credential username (demo) |
276284
| `ASB_BROWSER_PASSWORD` | Browser credential password (demo) |
277-
| `ASB_BROWSER_SELECTOR_USERNAME` | CSS selector for username field (demo) |
278-
| `ASB_BROWSER_SELECTOR_PASSWORD` | CSS selector for password field (demo) |
285+
| `ASB_BROWSER_SELECTOR_USERNAME` | Required CSS selector for username field when browser demo config is enabled |
286+
| `ASB_BROWSER_SELECTOR_PASSWORD` | Required CSS selector for password field when browser demo config is enabled |
287+
| `ASB_BROWSER_ALLOW_INSECURE_LOCALHOST` | Allow `http://localhost[:port]` for browser demo config in local development |
279288
| `ASB_NOTIFICATIONS_BASE_URL` | Shared notifications service base URL for pending approval delivery |
280289
| `ASB_NOTIFICATIONS_RECIPIENT_ID` | Recipient or queue ID for approval notifications |
281290
| `ASB_NOTIFICATIONS_CHANNEL` | Delivery channel for approval notifications (`slack`, `email`, `webhook`, `in_app`) |

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.26.0
55
require (
66
connectrpc.com/connect v1.19.1
77
github.com/alicebob/miniredis/v2 v2.37.0
8+
github.com/andybalholm/cascadia v1.3.3
89
github.com/evalops/proto v0.0.0-20260414193513-3db7075bd55b
910
github.com/evalops/service-runtime v0.1.42
1011
github.com/golang-jwt/jwt/v5 v5.3.1
@@ -39,6 +40,7 @@ require (
3940
go.opentelemetry.io/otel/trace v1.43.0 // indirect
4041
go.uber.org/atomic v1.11.0 // indirect
4142
go.yaml.in/yaml/v2 v2.4.2 // indirect
43+
golang.org/x/net v0.43.0 // indirect
4244
golang.org/x/sync v0.20.0 // indirect
4345
golang.org/x/sys v0.42.0 // indirect
4446
golang.org/x/text v0.35.0 // indirect

go.sum

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
22
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
33
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
44
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
5+
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
6+
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
57
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
68
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
79
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -30,6 +32,7 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
3032
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
3133
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
3234
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
35+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
3336
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3437
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
3538
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -75,6 +78,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
7578
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
7679
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
7780
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
81+
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
7882
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
7983
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
8084
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
@@ -99,12 +103,77 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
99103
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
100104
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
101105
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
106+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
107+
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
108+
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
109+
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
110+
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
111+
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
112+
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
113+
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
114+
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
115+
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
116+
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
117+
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
118+
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
119+
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
120+
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
121+
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
122+
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
123+
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
124+
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
125+
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
126+
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
127+
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
128+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
129+
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
130+
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
131+
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
132+
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
133+
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
134+
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
102135
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
103136
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
137+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
138+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
139+
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
140+
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
141+
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
142+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
143+
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
144+
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
145+
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
146+
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
147+
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
104148
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
105149
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
150+
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
151+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
152+
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
153+
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
154+
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
155+
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
156+
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
157+
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
158+
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
159+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
160+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
161+
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
162+
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
163+
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
164+
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
165+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
166+
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
167+
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
106168
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
107169
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
170+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
171+
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
172+
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
173+
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
174+
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
175+
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
176+
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
108177
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
109178
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
110179
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/bootstrap/service.go

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ func NewServiceRuntime(ctx context.Context, logger *slog.Logger, options ...Serv
132132
cleanupRepository()
133133
return nil, err
134134
}
135+
browserConnector, browserOrigin, err := newBrowserConnector()
136+
if err != nil {
137+
cleanupRuntime()
138+
cleanupRepository()
139+
return nil, err
140+
}
135141

136142
tenantID := getenv("ASB_DEV_TENANT_ID", "t_dev")
137143
mustRegisterToolAndPolicy(ctx, logger, tools, engine, tenantID, core.Tool{
@@ -154,25 +160,8 @@ func NewServiceRuntime(ctx context.Context, logger *slog.Logger, options ...Serv
154160
Condition: `request.tool == "github"`,
155161
})
156162

157-
if origin := os.Getenv("ASB_BROWSER_ORIGIN"); origin != "" {
158-
username := os.Getenv("ASB_BROWSER_USERNAME")
159-
password := os.Getenv("ASB_BROWSER_PASSWORD")
160-
userSelector := getenv("ASB_BROWSER_SELECTOR_USERNAME", "#username")
161-
passSelector := getenv("ASB_BROWSER_SELECTOR_PASSWORD", "#password")
162-
connectorOptions = append(connectorOptions, resolver.WithBrowser(browserconnector.NewConnector(browserconnector.Config{
163-
Credentials: browserconnector.StaticCredentialStore(map[string]browserconnector.Credential{
164-
origin: {
165-
Username: username,
166-
Password: password,
167-
},
168-
}),
169-
SelectorMaps: map[string]browserconnector.SelectorMap{
170-
origin: {
171-
Username: userSelector,
172-
Password: passSelector,
173-
},
174-
},
175-
})))
163+
if browserConnector != nil {
164+
connectorOptions = append(connectorOptions, resolver.WithBrowser(browserConnector))
176165
mustRegisterToolAndPolicy(ctx, logger, tools, engine, tenantID, core.Tool{
177166
TenantID: tenantID,
178167
Tool: "browser",
@@ -190,7 +179,7 @@ func NewServiceRuntime(ctx context.Context, logger *slog.Logger, options ...Serv
190179
MaxTTL: 5 * time.Minute,
191180
ApprovalMode: core.ApprovalModeLiveHuman,
192181
RequiredToolTags: []string{"trusted", "browser"},
193-
Condition: `request.origin == "` + origin + `" && session.tool_context.exists(t, t == "browser")`,
182+
Condition: `request.origin == "` + browserOrigin + `" && session.tool_context.exists(t, t == "browser")`,
194183
})
195184
}
196185

@@ -521,6 +510,54 @@ func newApprovalNotifier() (core.ApprovalNotifier, error) {
521510
})
522511
}
523512

513+
func newBrowserConnector() (*browserconnector.Connector, string, error) {
514+
origin := strings.TrimSpace(os.Getenv("ASB_BROWSER_ORIGIN"))
515+
username := strings.TrimSpace(os.Getenv("ASB_BROWSER_USERNAME"))
516+
password := os.Getenv("ASB_BROWSER_PASSWORD")
517+
userSelector := strings.TrimSpace(os.Getenv("ASB_BROWSER_SELECTOR_USERNAME"))
518+
passSelector := strings.TrimSpace(os.Getenv("ASB_BROWSER_SELECTOR_PASSWORD"))
519+
allowInsecureLocalhostRaw := strings.TrimSpace(os.Getenv("ASB_BROWSER_ALLOW_INSECURE_LOCALHOST"))
520+
521+
if origin == "" {
522+
if username != "" || password != "" || userSelector != "" || passSelector != "" || allowInsecureLocalhostRaw != "" {
523+
return nil, "", fmt.Errorf("ASB_BROWSER_ORIGIN is required when browser connector demo credentials are configured")
524+
}
525+
return nil, "", nil
526+
}
527+
if userSelector == "" || passSelector == "" {
528+
return nil, "", fmt.Errorf("ASB_BROWSER_SELECTOR_USERNAME and ASB_BROWSER_SELECTOR_PASSWORD are required when ASB_BROWSER_ORIGIN is set")
529+
}
530+
531+
allowInsecureLocalhost := false
532+
if allowInsecureLocalhostRaw != "" {
533+
parsed, err := strconv.ParseBool(allowInsecureLocalhostRaw)
534+
if err != nil {
535+
return nil, "", fmt.Errorf("parse ASB_BROWSER_ALLOW_INSECURE_LOCALHOST: %w", err)
536+
}
537+
allowInsecureLocalhost = parsed
538+
}
539+
540+
connector, err := browserconnector.NewConnector(browserconnector.Config{
541+
Credentials: browserconnector.StaticCredentialStore(map[string]browserconnector.Credential{
542+
origin: {
543+
Username: username,
544+
Password: password,
545+
},
546+
}),
547+
SelectorMaps: map[string]browserconnector.SelectorMap{
548+
origin: {
549+
Username: userSelector,
550+
Password: passSelector,
551+
},
552+
},
553+
AllowInsecureLocalhost: allowInsecureLocalhost,
554+
})
555+
if err != nil {
556+
return nil, "", err
557+
}
558+
return connector, origin, nil
559+
}
560+
524561
func newRepository(ctx context.Context) (core.Repository, func(), readinessProbe, func() sql.DBStats, error) {
525562
if dsn := os.Getenv("ASB_POSTGRES_DSN"); dsn != "" {
526563
pool, err := pgxpool.New(ctx, dsn)

internal/bootstrap/service_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,89 @@ func TestNewVerifierRequiresCompleteOIDCConfiguration(t *testing.T) {
107107
}
108108
}
109109

110+
func TestNewBrowserConnectorDisabledByDefault(t *testing.T) {
111+
t.Setenv("ASB_BROWSER_ORIGIN", "")
112+
t.Setenv("ASB_BROWSER_USERNAME", "")
113+
t.Setenv("ASB_BROWSER_PASSWORD", "")
114+
t.Setenv("ASB_BROWSER_SELECTOR_USERNAME", "")
115+
t.Setenv("ASB_BROWSER_SELECTOR_PASSWORD", "")
116+
t.Setenv("ASB_BROWSER_ALLOW_INSECURE_LOCALHOST", "")
117+
118+
connector, origin, err := newBrowserConnector()
119+
if err != nil {
120+
t.Fatalf("newBrowserConnector() error = %v", err)
121+
}
122+
if connector != nil || origin != "" {
123+
t.Fatalf("newBrowserConnector() = (%#v, %q), want disabled browser connector", connector, origin)
124+
}
125+
}
126+
127+
func TestNewBrowserConnectorRequiresSelectors(t *testing.T) {
128+
t.Setenv("ASB_BROWSER_ORIGIN", "https://admin.vendor.example")
129+
t.Setenv("ASB_BROWSER_USERNAME", "admin")
130+
t.Setenv("ASB_BROWSER_PASSWORD", "secret")
131+
t.Setenv("ASB_BROWSER_SELECTOR_USERNAME", "")
132+
t.Setenv("ASB_BROWSER_SELECTOR_PASSWORD", "")
133+
134+
connector, _, err := newBrowserConnector()
135+
if err == nil || !strings.Contains(err.Error(), "ASB_BROWSER_SELECTOR_USERNAME") {
136+
t.Fatalf("newBrowserConnector() error = %v, want selector requirement error", err)
137+
}
138+
if connector != nil {
139+
t.Fatalf("newBrowserConnector() = %#v, want nil", connector)
140+
}
141+
}
142+
143+
func TestNewBrowserConnectorRejectsInsecureOriginByDefault(t *testing.T) {
144+
t.Setenv("ASB_BROWSER_ORIGIN", "http://localhost:3000")
145+
t.Setenv("ASB_BROWSER_USERNAME", "admin")
146+
t.Setenv("ASB_BROWSER_PASSWORD", "secret")
147+
t.Setenv("ASB_BROWSER_SELECTOR_USERNAME", "#username")
148+
t.Setenv("ASB_BROWSER_SELECTOR_PASSWORD", "#password")
149+
t.Setenv("ASB_BROWSER_ALLOW_INSECURE_LOCALHOST", "")
150+
151+
connector, _, err := newBrowserConnector()
152+
if err == nil || !strings.Contains(err.Error(), "must use https") {
153+
t.Fatalf("newBrowserConnector() error = %v, want https enforcement error", err)
154+
}
155+
if connector != nil {
156+
t.Fatalf("newBrowserConnector() = %#v, want nil", connector)
157+
}
158+
}
159+
160+
func TestNewBrowserConnectorAllowsExplicitLocalhostOverride(t *testing.T) {
161+
t.Setenv("ASB_BROWSER_ORIGIN", "http://localhost:3000")
162+
t.Setenv("ASB_BROWSER_USERNAME", "admin")
163+
t.Setenv("ASB_BROWSER_PASSWORD", "secret")
164+
t.Setenv("ASB_BROWSER_SELECTOR_USERNAME", "#username")
165+
t.Setenv("ASB_BROWSER_SELECTOR_PASSWORD", "#password")
166+
t.Setenv("ASB_BROWSER_ALLOW_INSECURE_LOCALHOST", "true")
167+
168+
connector, origin, err := newBrowserConnector()
169+
if err != nil {
170+
t.Fatalf("newBrowserConnector() error = %v", err)
171+
}
172+
if connector == nil || origin != "http://localhost:3000" {
173+
t.Fatalf("newBrowserConnector() = (%#v, %q), want configured localhost connector", connector, origin)
174+
}
175+
}
176+
177+
func TestNewBrowserConnectorRejectsInvalidSelector(t *testing.T) {
178+
t.Setenv("ASB_BROWSER_ORIGIN", "https://admin.vendor.example")
179+
t.Setenv("ASB_BROWSER_USERNAME", "admin")
180+
t.Setenv("ASB_BROWSER_PASSWORD", "secret")
181+
t.Setenv("ASB_BROWSER_SELECTOR_USERNAME", "input[")
182+
t.Setenv("ASB_BROWSER_SELECTOR_PASSWORD", "#password")
183+
184+
connector, _, err := newBrowserConnector()
185+
if err == nil || !strings.Contains(err.Error(), "invalid browser username selector") {
186+
t.Fatalf("newBrowserConnector() error = %v, want selector validation error", err)
187+
}
188+
if connector != nil {
189+
t.Fatalf("newBrowserConnector() = %#v, want nil", connector)
190+
}
191+
}
192+
110193
func writeEd25519PublicKeyFile(t *testing.T, dir, name string) string {
111194
t.Helper()
112195

0 commit comments

Comments
 (0)