juanfont/headscale · error

%s confirm form: unterminated action attribute

Error message

%s confirm form: unterminated action attribute

What it means

While auto-submitting the headscale registration confirm form, the helper found an `action="` attribute in the rendered HTML but no closing double quote after it. This is a string-scrape parser in the integration Scenario that extracts the form POST URL from the register/confirm page; it throws when the HTML is truncated or the template emits malformed/quoted attributes.

Source

Thrown at integration/scenario.go:1263

// POSTs the form using the same HTTP client (which carries the CSRF
// cookie set by the callback).
func submitConfirmForm(
	hostname string,
	htmlBody string,
	prevResp *http.Response,
	hc *http.Client,
) (string, *url.URL, error) {
	// Extract form action URL.
	actionIdx := strings.Index(htmlBody, `action="`)
	if actionIdx == -1 {
		return "", nil, fmt.Errorf("%s confirm form: no action attribute", hostname) //nolint:err113
	}

	actionStart := actionIdx + len(`action="`)

	actionEnd := strings.Index(htmlBody[actionStart:], `"`)
	if actionEnd == -1 {
		return "", nil, fmt.Errorf("%s confirm form: unterminated action attribute", hostname) //nolint:err113
	}

	formAction := htmlBody[actionStart : actionStart+actionEnd]

	// Extract hidden CSRF input value. The rendered <input> has
	// attributes in name-type-value order so we grab the whole tag.
	before, _, ok := strings.Cut(htmlBody, `name="headscale_register_confirm"`)
	if !ok {
		return "", nil, fmt.Errorf("%s confirm form: no CSRF input", hostname) //nolint:err113
	}

	tagStart := strings.LastIndex(before, "<input")
	if tagStart == -1 {
		return "", nil, fmt.Errorf("%s confirm form: no input tag for CSRF", hostname) //nolint:err113
	}

	tagEnd := strings.Index(htmlBody[tagStart:], ">")
	if tagEnd == -1 {

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Re-fetch the confirm page with curl and inspect the raw HTML around `action=` to see what the server actually rendered.
  2. If the template changed, update this parser (or the template) in integration/scenario.go so the attribute format matches.
  3. Replace the string scrape with golang.org/x/net/html parsing so attribute quoting/escaping no longer breaks extraction.
  4. Verify the response is actually the confirm form (status 200, expected title) before parsing it.

Example fix

// before
actionEnd := strings.Index(htmlBody[actionStart:], `"`)
if actionEnd == -1 {
    return "", nil, fmt.Errorf("%s confirm form: unterminated action attribute", hostname)
}

// after: parse the form with x/net/html instead of string indexing
doc, err := html.Parse(strings.NewReader(htmlBody))
if err != nil {
    return "", nil, fmt.Errorf("%s confirm form: unparseable HTML: %w", hostname, err)
}
Defensive patterns

Strategy: validation

Validate before calling

// Before scraping, cheap sanity check that the body looks like the confirm form.
if !strings.Contains(htmlBody, "<form") || !strings.Contains(htmlBody, "action=") {
    return "", nil, fmt.Errorf("%s confirm form: response is not a form page", hostname)
}

Prevention

When it happens

Trigger: Calling the Scenario registration helper that fetches the confirm page and then runs this action-extraction code: `strings.Index(htmlBody, "action=\"")` matches, but `strings.Index(htmlBody[actionStart:], "\"")` returns -1. Happens when the response body is cut off mid-attribute, when the template uses single quotes after a double quote, or when HTML-escaping inserts a quote inside the action URL.

Common situations: The hscontrol template for the register confirm page changed attribute quoting; a reverse proxy truncates the body; the auth flow returned an error page whose first `action="` occurrence is inside JavaScript or a comment rather than the form.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/2a16c9afa66f516d. Report an issue: GitHub.