Fix mobile onboarding URL handoff sanitization
Some checks are pending
Canonical Governance / governance (push) Waiting to run
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run
Helm CI / Lint and Render Chart (push) Waiting to run
Core E2E Tests / Playwright Core E2E (push) Waiting to run

This commit is contained in:
rcourtman 2026-07-07 15:12:59 +01:00
parent e1cddca2d1
commit 57139c65c5
10 changed files with 156 additions and 11 deletions

View file

@ -8997,6 +8997,45 @@ func TestContract_OnboardingQRResponseJSONSnapshot(t *testing.T) {
assertJSONSnapshot(t, got, want)
}
func TestContract_OnboardingQRResponseOmitsEmptyInstanceURLJSONSnapshot(t *testing.T) {
payload := onboardingQRResponse{
Schema: onboardingSchemaVersion,
InstanceID: "relay_abc123",
Relay: onboardingRelayDetails{
Enabled: true,
URL: "wss://relay.example.test/ws/app",
},
AuthToken: "token-123",
DeepLink: "pulse://connect?schema=pulse-mobile-onboarding-v1&instance_id=relay_abc123&relay_url=wss%3A%2F%2Frelay.example.test%2Fws%2Fapp&auth_token=token-123",
Diagnostics: []onboardingDiagnostic{
{
Code: "instance_url_not_https",
Severity: "warning",
Field: "instance_url",
Expected: "https://...",
Received: "http://pulse.local.test",
Message: "Pulse web link is not included in this pairing code because the resolved Pulse URL is not HTTPS. Pairing can continue, but opening Pulse web from the phone requires an HTTPS Pulse URL.",
},
},
}.normalizeCollections()
got, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal onboarding qr response without instance_url: %v", err)
}
const want = `{
"schema":"pulse-mobile-onboarding-v1",
"instance_id":"relay_abc123",
"relay":{"enabled":true,"url":"wss://relay.example.test/ws/app"},
"auth_token":"token-123",
"deep_link":"pulse://connect?schema=pulse-mobile-onboarding-v1\u0026instance_id=relay_abc123\u0026relay_url=wss%3A%2F%2Frelay.example.test%2Fws%2Fapp\u0026auth_token=token-123",
"diagnostics":[{"code":"instance_url_not_https","severity":"warning","message":"Pulse web link is not included in this pairing code because the resolved Pulse URL is not HTTPS. Pairing can continue, but opening Pulse web from the phone requires an HTTPS Pulse URL.","field":"instance_url","expected":"https://...","received":"http://pulse.local.test"}]
}`
assertJSONSnapshot(t, got, want)
}
func TestContract_OnboardingNotReadyResponseJSONSnapshot(t *testing.T) {
payload := onboardingNotReadyResponse{
Code: onboardingNotReadyCode,

View file

@ -25,7 +25,7 @@ type onboardingRelayDetails struct {
type onboardingQRResponse struct {
Schema string `json:"schema"`
InstanceURL string `json:"instance_url"`
InstanceURL string `json:"instance_url,omitempty"`
InstanceID string `json:"instance_id,omitempty"`
Relay onboardingRelayDetails `json:"relay"`
AuthToken string `json:"auth_token"`
@ -288,7 +288,8 @@ func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Confi
payload := emptyOnboardingQRResponse()
payload.Schema = onboardingSchemaVersion
payload.InstanceURL = strings.TrimSpace(r.resolvePublicURL(req))
instanceURL, instanceURLDiagnostic := normalizeOnboardingInstanceURL(r.resolvePublicURL(req))
payload.InstanceURL = instanceURL
payload.InstanceID = r.currentRelayInstanceID()
payload.Relay = onboardingRelayDetails{
Enabled: relayCfg.Enabled,
@ -299,6 +300,9 @@ func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Confi
payload.AuthToken = authToken
diagnostics := make([]onboardingDiagnostic, 0, 4)
if instanceURLDiagnostic != nil {
diagnostics = append(diagnostics, *instanceURLDiagnostic)
}
if !payload.Relay.Enabled {
diagnostics = append(diagnostics, onboardingDiagnostic{
Code: "relay_disabled",
@ -329,6 +333,27 @@ func (r *Router) buildOnboardingPayload(req *http.Request, relayCfg *relay.Confi
return payload, diagnostics
}
func normalizeOnboardingInstanceURL(raw string) (string, *onboardingDiagnostic) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
parsed, err := url.Parse(raw)
if err == nil && strings.EqualFold(parsed.Scheme, "https") && strings.TrimSpace(parsed.Host) != "" {
return raw, nil
}
return "", &onboardingDiagnostic{
Code: "instance_url_not_https",
Severity: "warning",
Field: "instance_url",
Expected: "https://...",
Received: raw,
Message: "Pulse web link is not included in this pairing code because the resolved Pulse URL is not HTTPS. Pairing can continue, but opening Pulse web from the phone requires an HTTPS Pulse URL.",
}
}
func writeOnboardingNotReady(w http.ResponseWriter, diagnostics []onboardingDiagnostic) {
response := onboardingNotReadyResponse{
Code: onboardingNotReadyCode,
@ -456,7 +481,9 @@ func hasOnboardingError(diagnostics []onboardingDiagnostic) bool {
func buildOnboardingDeepLink(payload onboardingQRResponse) string {
query := url.Values{}
query.Set("schema", payload.Schema)
query.Set("instance_url", payload.InstanceURL)
if payload.InstanceURL != "" {
query.Set("instance_url", payload.InstanceURL)
}
if payload.InstanceID != "" {
query.Set("instance_id", payload.InstanceID)
}

View file

@ -56,6 +56,45 @@ func TestOnboardingQRPayloadStructure(t *testing.T) {
}
}
func TestOnboardingQROmitsNonHTTPSInstanceURL(t *testing.T) {
router, rawToken, _ := newOnboardingContractRouter(t)
router.config.PublicURL = ""
router.config.AgentConnectURL = ""
req := httptest.NewRequest(http.MethodGet, "http://pulse.local.test/api/onboarding/qr", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.handleGetOnboardingQR(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(body), &raw); err != nil {
t.Fatalf("decode raw QR response: %v", err)
}
if _, ok := raw["instance_url"]; ok {
t.Fatalf("expected unsafe top-level instance_url to be omitted from QR response, got %s", body)
}
var payload onboardingQRResponse
if err := json.NewDecoder(strings.NewReader(body)).Decode(&payload); err != nil {
t.Fatalf("decode QR response: %v", err)
}
if payload.InstanceURL != "" {
t.Fatalf("expected empty instance_url, got %q", payload.InstanceURL)
}
if strings.Contains(payload.DeepLink, "instance_url") {
t.Fatalf("expected unsafe instance_url to be omitted from deep link, got %q", payload.DeepLink)
}
if !diagnosticCodePresent(payload.Diagnostics, "instance_url_not_https") {
t.Fatalf("expected instance_url_not_https diagnostic, got %#v", payload.Diagnostics)
}
}
func TestOnboardingQRRejectsIncompleteRelayRegistration(t *testing.T) {
router, rawToken, _ := newOnboardingContractRouter(t)
router.relayClient = nil