Skip to content

fix: correct URL validation and centralize logic #421

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions provider/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ package provider

import (
"context"
"net/url"
"regexp"

"github.com/google/uuid"
"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"

"github.com/coder/terraform-provider-coder/v2/provider/helpers"
)

var (
Expand Down Expand Up @@ -93,15 +94,9 @@ func appResource() *schema.Resource {
Description: "A URL to an icon that will display in the dashboard. View built-in " +
"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
ForceNew: true,
Optional: true,
ValidateFunc: func(i any, s string) ([]string, []error) {
_, err := url.Parse(s)
if err != nil {
return nil, []error{err}
}
return nil, nil
},
ForceNew: true,
Optional: true,
ValidateFunc: helpers.ValidateURL,
},
"slug": {
Type: schema.TypeString,
Expand Down
57 changes: 57 additions & 0 deletions provider/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,4 +478,61 @@ func TestApp(t *testing.T) {
})
}
})

t.Run("Icon", func(t *testing.T) {
t.Parallel()

cases := []struct {
name string
icon string
expectError *regexp.Regexp
}{
{
name: "Empty",
icon: "",
},
{
name: "ValidURL",
icon: "/icon/region.svg",
},
{
name: "InvalidURL",
icon: "/icon%.svg",
expectError: regexp.MustCompile("invalid URL escape"),
},
}

for _, c := range cases {
c := c

t.Run(c.name, func(t *testing.T) {
t.Parallel()

config := fmt.Sprintf(`
provider "coder" {}
resource "coder_agent" "dev" {
os = "linux"
arch = "amd64"
}
resource "coder_app" "code-server" {
agent_id = coder_agent.dev.id
slug = "code-server"
display_name = "Testing"
url = "http://localhost:13337"
open_in = "slim-window"
icon = "%s"
}
`, c.icon)

resource.Test(t, resource.TestCase{
ProviderFactories: coderFactory(),
IsUnitTest: true,
Steps: []resource.TestStep{{
Config: config,
ExpectError: c.expectError,
}},
})
})
}
})
}
22 changes: 22 additions & 0 deletions provider/helpers/validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package helpers

import (
"fmt"
"net/url"
)

// ValidateURL validates that value is a valid URL string.
// Accepts empty strings, local file paths, file:// URLs, and http/https URLs.
// Example: for `icon = "/icon/region.svg"`, value is `/icon/region.svg` and label is `icon`.
func ValidateURL(value any, label string) ([]string, []error) {
val, ok := value.(string)
if !ok {
return nil, []error{fmt.Errorf("expected %q to be a string", label)}
}

if _, err := url.Parse(val); err != nil {
return nil, []error{err}
}

return nil, nil
}
151 changes: 151 additions & 0 deletions provider/helpers/validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package helpers

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestValidateURL(t *testing.T) {
tests := []struct {
name string
value any
label string
expectError bool
errorContains string
}{
// Valid cases
{
name: "empty string",
value: "",
label: "url",
expectError: false,
},
{
name: "valid http URL",
value: "http://example.com",
label: "url",
expectError: false,
},
{
name: "valid https URL",
value: "https://example.com/path",
label: "url",
expectError: false,
},
{
name: "absolute file path",
value: "/path/to/file",
label: "url",
expectError: false,
},
{
name: "relative file path",
value: "./file.txt",
label: "url",
expectError: false,
},
{
name: "relative path up directory",
value: "../config.json",
label: "url",
expectError: false,
},
{
name: "simple filename",
value: "file.txt",
label: "url",
expectError: false,
},
{
name: "URL with query params",
value: "https://example.com/search?q=test",
label: "url",
expectError: false,
},
{
name: "URL with fragment",
value: "https://example.com/page#section",
label: "url",
expectError: false,
},

// Various URL schemes that url.Parse accepts
{
name: "file URL scheme",
value: "file:///path/to/file",
label: "url",
expectError: false,
},
{
name: "ftp scheme",
value: "ftp://files.example.com/file.txt",
label: "url",
expectError: false,
},
{
name: "mailto scheme",
value: "mailto:user@example.com",
label: "url",
expectError: false,
},
{
name: "tel scheme",
value: "tel:+1234567890",
label: "url",
expectError: false,
},
{
name: "data scheme",
value: "data:text/plain;base64,SGVsbG8=",
label: "url",
expectError: false,
},

// Invalid cases
{
name: "non-string type - int",
value: 123,
label: "url",
expectError: true,
errorContains: "expected \"url\" to be a string",
},
{
name: "non-string type - nil",
value: nil,
label: "config_url",
expectError: true,
errorContains: "expected \"config_url\" to be a string",
},
{
name: "invalid URL with spaces",
value: "http://example .com",
label: "url",
expectError: true,
errorContains: "invalid character",
},
{
name: "malformed URL",
value: "http://[::1:80",
label: "endpoint",
expectError: true,
errorContains: "missing ']'",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
warnings, errors := ValidateURL(tt.value, tt.label)

if tt.expectError {
require.Len(t, errors, 1, "expected an error but got none")
require.Contains(t, errors[0].Error(), tt.errorContains)
} else {
require.Empty(t, errors, "expected no errors but got: %v", errors)
}

// Should always return nil for warnings
require.Nil(t, warnings, "expected warnings to be nil but got: %v", warnings)
})
}
}
15 changes: 5 additions & 10 deletions provider/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ package provider

import (
"context"
"net/url"

"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"golang.org/x/xerrors"

"github.com/coder/terraform-provider-coder/v2/provider/helpers"
)

func metadataResource() *schema.Resource {
Expand Down Expand Up @@ -56,15 +57,9 @@ func metadataResource() *schema.Resource {
Description: "A URL to an icon that will display in the dashboard. View built-in " +
"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
ForceNew: true,
Optional: true,
ValidateFunc: func(i interface{}, s string) ([]string, []error) {
_, err := url.Parse(s)
if err != nil {
return nil, []error{err}
}
return nil, nil
},
ForceNew: true,
Optional: true,
ValidateFunc: helpers.ValidateURL,
},
"daily_cost": {
Type: schema.TypeInt,
Expand Down
27 changes: 8 additions & 19 deletions provider/parameter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"regexp"
"strconv"
Expand All @@ -19,6 +18,8 @@ import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
"github.com/mitchellh/mapstructure"
"golang.org/x/xerrors"

"github.com/coder/terraform-provider-coder/v2/provider/helpers"
)

var (
Expand Down Expand Up @@ -223,15 +224,9 @@ func parameterDataSource() *schema.Resource {
Description: "A URL to an icon that will display in the dashboard. View built-in " +
"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
ForceNew: true,
Optional: true,
ValidateFunc: func(i interface{}, s string) ([]string, []error) {
_, err := url.Parse(s)
if err != nil {
return nil, []error{err}
}
return nil, nil
},
ForceNew: true,
Optional: true,
ValidateFunc: helpers.ValidateURL,
},
"option": {
Type: schema.TypeList,
Expand Down Expand Up @@ -263,15 +258,9 @@ func parameterDataSource() *schema.Resource {
Description: "A URL to an icon that will display in the dashboard. View built-in " +
"icons [here](https://github.com/coder/coder/tree/main/site/static/icon). Use a " +
"built-in icon with `\"${data.coder_workspace.me.access_url}/icon/<path>\"`.",
ForceNew: true,
Optional: true,
ValidateFunc: func(i interface{}, s string) ([]string, []error) {
_, err := url.Parse(s)
if err != nil {
return nil, []error{err}
}
return nil, nil
},
ForceNew: true,
Optional: true,
ValidateFunc: helpers.ValidateURL,
},
},
},
Expand Down
28 changes: 27 additions & 1 deletion provider/parameter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,33 @@ data "coder_parameter" "region" {
}
`,
ExpectError: regexp.MustCompile("ephemeral parameter requires the default property"),
}} {
}, {
Name: "InvalidIconURL",
Config: `
data "coder_parameter" "region" {
name = "Region"
type = "string"
icon = "/icon%.svg"
}
`,
ExpectError: regexp.MustCompile("invalid URL escape"),
}, {
Name: "OptionInvalidIconURL",
Config: `
data "coder_parameter" "region" {
name = "Region"
type = "string"
option {
name = "1"
value = "1"
icon = "/icon%.svg"
description = "Something!"
}
}
`,
ExpectError: regexp.MustCompile("invalid URL escape"),
},
} {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
Expand Down
Loading
Loading