diff --git a/cli/cmd/aasa_validate.go b/cli/cmd/aasa_validate.go index 569b69d..2b54e17 100644 --- a/cli/cmd/aasa_validate.go +++ b/cli/cmd/aasa_validate.go @@ -8,12 +8,12 @@ import ( "github.com/spf13/cobra" ) -// validateCmd represents the validate command -var validateCmd = &cobra.Command{ +// validateAASACmd represents the validate command for Apple App Site Association +var validateAASACmd = &cobra.Command{ Use: "validate ", Short: "Validate your link against Apple's requirements", Run: func(cmd *cobra.Command, args []string) { - output := yurllib.CheckDomain(args[0], "", "", true) + output := yurllib.CheckAASADomain(args[0], "", "", true) for _, item := range output { fmt.Print(item) @@ -23,5 +23,5 @@ var validateCmd = &cobra.Command{ } func init() { - aasaCmd.AddCommand(validateCmd) + aasaCmd.AddCommand(validateAASACmd) } diff --git a/cli/cmd/assetlink.go b/cli/cmd/assetlink.go new file mode 100644 index 0000000..5502eb2 --- /dev/null +++ b/cli/cmd/assetlink.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var ( + assetlinkCmd = &cobra.Command{ + Use: "assetlink", + Short: "Command for Android Asset Link utils.", + } +) + +func init() { + rootCmd.AddCommand(assetlinkCmd) +} diff --git a/cli/cmd/assetlink_validate.go b/cli/cmd/assetlink_validate.go new file mode 100644 index 0000000..b5dce24 --- /dev/null +++ b/cli/cmd/assetlink_validate.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + + "github.com/chayev/yurl/yurllib" + + "github.com/spf13/cobra" +) + +// validateAssetLinkCmd represents the validate command for ASset Links +var validateAssetLinkCmd = &cobra.Command{ + Use: "validate ", + Short: "Validate your link against Android's requirements", + Run: func(cmd *cobra.Command, args []string) { + output := yurllib.CheckAssetLinkDomain(args[0], "", "") + + for _, item := range output { + fmt.Print(item) + } + + }, +} + +func init() { + assetlinkCmd.AddCommand(validateAssetLinkCmd) +} diff --git a/webapp/main.go b/webapp/main.go index 3f3972b..b6f277d 100644 --- a/webapp/main.go +++ b/webapp/main.go @@ -16,6 +16,8 @@ func main() { log.Println("Listening on :8080...") http.HandleFunc("/", handler) http.HandleFunc("/results", viewResults) + http.HandleFunc("/android", android) + http.HandleFunc("/android-results", viewResultsAndroid) log.Fatal(http.ListenAndServe(":8080", nil)) } @@ -32,7 +34,7 @@ func handler(w http.ResponseWriter, r *http.Request) { content := &PageOutput{CurrentTime: time.Now()} - t, _ := template.ParseFiles("tpl/home.html", "tpl/partials/header.html", "tpl/partials/footer.html") + t, _ := template.ParseFiles("tpl/home.html", "tpl/partials/header.html", "tpl/partials/footer.html", "tpl/partials/navToAndroid.html") t.ExecuteTemplate(w, "home.html", &content) } @@ -59,7 +61,7 @@ func viewResults(w http.ResponseWriter, r *http.Request) { if url == "" { output = append(output, "Enter URL to validate.") } else { - output = yurllib.CheckDomain(url, prefix, bundle, true) + output = yurllib.CheckAASADomain(url, prefix, bundle, true) } content := &PageOutput{URL: url, Prefix: prefix, Bundle: bundle} @@ -70,6 +72,52 @@ func viewResults(w http.ResponseWriter, r *http.Request) { content.CurrentTime = time.Now() - t, _ := template.ParseFiles("tpl/results.html", "tpl/partials/header.html", "tpl/partials/footer.html") + t, _ := template.ParseFiles("tpl/results.html", "tpl/partials/header.html", "tpl/partials/footer.html", "tpl/partials/navToAndroid.html") t.ExecuteTemplate(w, "results.html", &content) } + +func android(w http.ResponseWriter, r *http.Request) { + + content := &PageOutput{CurrentTime: time.Now()} + + t, _ := template.ParseFiles("tpl/android.html", "tpl/partials/header.html", "tpl/partials/footer.html", "tpl/partials/navToiOS.html") + t.ExecuteTemplate(w, "android.html", &content) +} + +func viewResultsAndroid(w http.ResponseWriter, r *http.Request) { + + var url string + var package_name string + var fingerprint string + + for _, n := range r.URL.Query()["url"] { + url = n + } + + for _, n := range r.URL.Query()["prefix"] { + package_name = n + } + + for _, n := range r.URL.Query()["bundle"] { + fingerprint = n + } + + var output []string + + if url == "" { + output = append(output, "Enter URL to validate.") + } else { + output = yurllib.CheckAssetLinkDomain(url, package_name, fingerprint) + } + + content := &PageOutput{URL: url, Prefix: package_name, Bundle: fingerprint} + + for _, item := range output { + content.Content += item + } + + content.CurrentTime = time.Now() + + t, _ := template.ParseFiles("tpl/android-results.html", "tpl/partials/header.html", "tpl/partials/footer.html", "tpl/partials/navToiOS.html") + t.ExecuteTemplate(w, "android-results.html", &content) +} diff --git a/webapp/static/css/main.css b/webapp/static/css/main.css index 0afed5c..013ccce 100644 --- a/webapp/static/css/main.css +++ b/webapp/static/css/main.css @@ -167,4 +167,4 @@ footer a { padding-left: 15px; padding-right: 15px; } -} \ No newline at end of file +} diff --git a/webapp/tpl/android-results.html b/webapp/tpl/android-results.html new file mode 100644 index 0000000..e1fc88b --- /dev/null +++ b/webapp/tpl/android-results.html @@ -0,0 +1,27 @@ +{{ template "header" }} + +
+

yURL: Asset Links File Validator

+ +
+ {{ template "navToiOS" }} +
+ +

Results:

+
{{ .Content }}
+
+ {{ template "footer" . }} + + diff --git a/webapp/tpl/android.html b/webapp/tpl/android.html new file mode 100644 index 0000000..379c159 --- /dev/null +++ b/webapp/tpl/android.html @@ -0,0 +1,17 @@ +{{ template "header" }} + + {{ template "navToiOS" }} +
+
+

yURL: Asset Links File Validator

+

yURL allows you to validate whether a URL is properly configured for Android Asset Links. This allows you to check if the assetlinks.json file exists and is in the proper format as defined by Google.

+
+ + + + +
+
+ {{ template "footer" . }} + + diff --git a/webapp/tpl/home.html b/webapp/tpl/home.html index 7b66452..c327dad 100644 --- a/webapp/tpl/home.html +++ b/webapp/tpl/home.html @@ -1,5 +1,6 @@ {{ template "header" }} + {{ template "navToAndroid" }}

yURL: Universal Links / AASA File Validator

diff --git a/webapp/tpl/partials/navToAndroid.html b/webapp/tpl/partials/navToAndroid.html new file mode 100644 index 0000000..259e42d --- /dev/null +++ b/webapp/tpl/partials/navToAndroid.html @@ -0,0 +1,5 @@ +{{ define "navToAndroid" }} +
+ Click here to validate Android Deep Linking instead +
+{{end}} diff --git a/webapp/tpl/partials/navToiOS.html b/webapp/tpl/partials/navToiOS.html new file mode 100644 index 0000000..89cdbc7 --- /dev/null +++ b/webapp/tpl/partials/navToiOS.html @@ -0,0 +1,5 @@ +{{ define "navToiOS" }} +
+ Click here to validate iOS Deep Linking instead +
+{{end}} diff --git a/webapp/tpl/results.html b/webapp/tpl/results.html index 33e7af1..48177da 100644 --- a/webapp/tpl/results.html +++ b/webapp/tpl/results.html @@ -4,6 +4,7 @@

yURL: Universal Links / AASA File Validator

+ {{ template "navToAndroid" }}
diff --git a/yurllib/aasa.go b/yurllib/aasa.go index 2e3b62b..21b38f5 100644 --- a/yurllib/aasa.go +++ b/yurllib/aasa.go @@ -6,7 +6,6 @@ import ( "fmt" "io/ioutil" "net/http" - "net/url" "strings" "go.mozilla.org/pkcs7" @@ -49,8 +48,8 @@ type genericService struct { Apps []string `json:"apps,omitempty"` } -// CheckDomain : Main function used by CLI and WebApp -func CheckDomain(inputURL string, bundleIdentifier string, teamIdentifier string, allowUnencrypted bool) []string { +// CheckAASADomain : Main function used by CLI and WebApp for Apple App Site Association validation +func CheckAASADomain(inputURL string, bundleIdentifier string, teamIdentifier string, allowUnencrypted bool) []string { var output []string @@ -118,31 +117,6 @@ func CheckDomain(inputURL string, bundleIdentifier string, teamIdentifier string return output } -func getDomain(input string) (string, []string) { - - var output []string - - //Clean up domains, removing scheme and path - parsedURL, err := url.Parse(input) - if err != nil { - output = append(output, fmt.Sprintf("The URL failed to parse with error %s \n", err)) - } - - scheme := parsedURL.Scheme - - if scheme != "https" { - output = append(output, fmt.Sprintf("WARNING: The URL must use HTTPS, trying HTTPS instead. \n\n")) - - parsedURL.Scheme = "https" - parsedURL, err = url.Parse(parsedURL.String()) - if err != nil { - output = append(output, fmt.Sprintf("The URL failed to parse with error %s \n", err)) - } - } - - return parsedURL.Host, output -} - func loadAASAContents(domain string) (*http.Response, []string, []error) { var output []string @@ -183,16 +157,6 @@ func loadAASAContents(domain string) (*http.Response, []string, []error) { return nil, output, formatErrors } -func makeRequest(fileURL string) (*http.Response, error) { - - resp, err := http.Get(fileURL) - if err != nil { - return nil, err - } - - return resp, nil -} - func evaluateAASA(result []byte, contentType []string, bundleIdentifier string, teamIdentifier string, encrypted bool) ([]string, []error) { var output []string @@ -231,7 +195,7 @@ func evaluateAASA(result []byte, contentType []string, bundleIdentifier string, output = append(output, fmt.Sprintln("JSON Validation: \t\t Pass")) - validJSON, formatErrors := verifyJSONformat(reqResp) + validJSON, formatErrors := verifyAASAJSONformat(reqResp) if validJSON { output = append(output, fmt.Sprintln("JSON Schema: \t\t\t Pass")) @@ -263,7 +227,7 @@ func evaluateAASA(result []byte, contentType []string, bundleIdentifier string, } -func verifyJSONformat(content aasaFile) (bool, []error) { +func verifyAASAJSONformat(content aasaFile) (bool, []error) { appLinks := content.Applinks diff --git a/yurllib/assetlink.go b/yurllib/assetlink.go new file mode 100644 index 0000000..ac28501 --- /dev/null +++ b/yurllib/assetlink.go @@ -0,0 +1,255 @@ +package yurllib + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" +) + +type assetLinkFile []struct { + Target Target `json:"target,omitempty"` + Relation []string `json:"relation,omitempty"` +} +type Target struct { + PackageName string `json:"package_name,omitempty"` + Sha256CertFingerprints []string `json:"sha256_cert_fingerprints,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +//https://developer.android.com/training/app-links/verify-android-applinks#publish-json +//https://developers.google.com/digital-asset-links/reference/rest/v1/Asset +//https://github.com/google/digitalassetlinks/blob/master/well-known/details.md +//Above defines requirements for Asset Links + +// CheckAssetLinkDomain : Main function used by CLI and WebApp for Android App Link validation +func CheckAssetLinkDomain(inputURL string, packageInput string, fingerprintInput string) []string { + + var output []string + + cleanedDomain, messages := getDomain(inputURL) + + output = append(output, messages...) + + rawResult, message, errors := loadAssetLinkContents(cleanedDomain) + if len(errors) > 0 { + for _, e := range errors { + output = append(output, fmt.Sprintf(" %s\n", e)) + } + return output + } + defer rawResult.Body.Close() + + output = append(output, message...) + + contentType := rawResult.Header["Content-Type"] + + output = append(output, fmt.Sprintf("Content-type: \t\t\t %s \n", contentType)) + + if contentType[0] != "application/json" { + output = append(output, fmt.Sprint("\nInvalid content type. Expecting [application/json]. Please update and test again. \n")) + output = append(output, fmt.Sprint("\nIf you believe this error is invalid, please open an issue on github or email support@chayev.com and we will investigate.")) + return output + } + + result, err := ioutil.ReadAll(rawResult.Body) + if err != nil { + return output + } + + messages, errorsx := evaluateAssetLink(result, packageInput, fingerprintInput) + if len(errorsx) > 0 { + output = append(output, fmt.Sprintf("\n %s\n", "Errors: ")) + for _, e := range errorsx { + output = append(output, fmt.Sprintf(" - %s\n", e)) + } + + output = append(output, fmt.Sprintf("\n %s\n", "File Contents: ")) + output = append(output, fmt.Sprintf("\n %s\n", result)) + return output + } + + output = append(output, messages...) + + return output +} + +func loadAssetLinkContents(domain string) (*http.Response, []string, []error) { + + var output []string + var formatErrors []error + var respStatus int + + wellKnownPath := "https://" + domain + "/.well-known/assetlinks.json" + + // Testing URLs + // wellKnownPath = "https://chayev.github.io/webpage-sandbox/assetlinks.json" + // wellKnownPath = "https://chayev.github.io/webpage-sandbox/assetlinks-empty.json" + // wellKnownPath = "https://chayev.github.io/webpage-sandbox/assetlinks-invalidnamespace.json" + // wellKnownPath = "https://chayev.github.io/webpage-sandbox/assetlinks-nopackage.json" + // wellKnownPath = "https://chayev.github.io/webpage-sandbox/assetlinks-nofp.json" + // wellKnownPath = "https://chayev.github.io/webpage-sandbox/assetlinks-emptyfp.json" + // wellKnownPath = "https://chayev.github.io/webpage-sandbox/assetlinks-malformed.json" + // wellKnownPath = "https://chayev.github.io/webpage-sandbox/assetlinks-onlynamespace.json" + + resp, err := makeRequest(wellKnownPath) + if err == nil { + respStatus = resp.StatusCode + + if respStatus >= 200 && respStatus < 300 { + output = append(output, fmt.Sprintf("Found file at:\n %s\n\n", wellKnownPath)) + output = append(output, fmt.Sprintln("No Redirect: \t\t\t Pass")) + return resp, output, nil + } + } else { + formatErrors = append(formatErrors, fmt.Errorf("Error: %w", err)) + } + + formatErrors = append(formatErrors, errors.New("It looks like this domain does not have Android Asset Links configured. No json file found in expected location.")) + + return nil, output, formatErrors +} + +func evaluateAssetLink(result []byte, packageInput string, fingerprintInput string) ([]string, []error) { + + var output []string + var formatErrors []error + + var reqResp assetLinkFile + + err := json.Unmarshal(result, &reqResp) + if err != nil { + prettyJSON, err := json.MarshalIndent(result, "", " ") + if err != nil { + formatErrors = append(formatErrors, fmt.Errorf("ioutil.ReadAll failed to parse with error: \n%w", err)) //define this better + return output, formatErrors + } + output = append(output, fmt.Sprintln("JSON Validation: Fail")) + + output = append(output, fmt.Sprintf("%s\n", string(prettyJSON))) + + return output, formatErrors + } + + output = append(output, fmt.Sprintln("JSON Validation: \t\t Pass")) + + validJSON, formatErrors := verifyAssetLinkJSONformat(reqResp) + + prettyJSON, err := json.MarshalIndent(reqResp, "", " ") + if err != nil { + formatErrors = append(formatErrors, fmt.Errorf("ioutil.ReadAll failed to parse with error: \n%w", err)) //define this better + return output, formatErrors + } + + if validJSON { + output = append(output, fmt.Sprintln("JSON Schema: \t\t\t Pass")) + + if packageInput != "" && fingerprintInput != "" { + if verifyInputIsPresent(reqResp, packageInput, fingerprintInput) { + output = append(output, fmt.Sprintln("Package/Fingerprint availability: Pass")) + } else { + output = append(output, fmt.Sprintln("Package/Fingerprint availability: Fail")) + } + } + + output = append(output, fmt.Sprintf("\n%s\n", string(prettyJSON))) + + } else { + output = append(output, fmt.Sprintln("\nJSON Schema: Fail")) + output = append(output, fmt.Sprintln("\nTarget must have an 'android_app' namespace with both a 'package_name' and atleast one 'sha256_cert_fingerprints' in an array.")) + + for _, formatError := range formatErrors { + output = append(output, fmt.Sprintf("\n %s\n", "Errors: ")) + output = append(output, fmt.Sprintf(" - %s\n", formatError)) + } + + output = append(output, fmt.Sprintf("\n%s\n", string(prettyJSON))) + + return output, formatErrors + } + + return output, formatErrors + +} + +func verifyAssetLinkJSONformat(content assetLinkFile) (bool, []error) { + + assetLinks := content //Array + + var formatErrors []error + + isValid := false + + hasAppNamespace := false + hasPackageName := false + hasFingerprint := false + + if assetLinks == nil { + formatErrors = append(formatErrors, errors.New("No data found in the file.")) + } else { + for _, assetLink := range assetLinks { + namespace := assetLink.Target.Namespace + packageName := assetLink.Target.PackageName + fingerprints := assetLink.Target.Sha256CertFingerprints + + if namespace == "android_app" { + hasAppNamespace = true + + if len(packageName) != 0 { + hasPackageName = true + + for _, fingerprint := range fingerprints { + if len(fingerprint) != 0 { + hasFingerprint = true + isValid = true + } + } + } + } + if len(packageName) != 0 { + hasPackageName = true + } + + for _, fingerprint := range fingerprints { + if len(fingerprint) != 0 { + hasFingerprint = true + } + } + } + } + + if !hasAppNamespace { + formatErrors = append(formatErrors, errors.New("None of the targets contains a namespace for 'android_app'.")) + } + + if !hasPackageName { + formatErrors = append(formatErrors, errors.New("Target with 'android_app' namespace must have 'package_name'.")) + } + + if !hasFingerprint { + formatErrors = append(formatErrors, errors.New("Target with 'android_app' namespace must have 'sha256_cert_fingerprints'.")) + } + + return isValid, formatErrors + +} + +func verifyInputIsPresent(content assetLinkFile, packageInput string, fingerprintInput string) bool { + + for i := 0; i < len(content); i++ { + record := content[i].Target + if record.Namespace == "android_app" { + if record.PackageName == packageInput { + for j := 0; j < len(record.Sha256CertFingerprints); j++ { + fingerprint := record.Sha256CertFingerprints[j] + if fingerprint == fingerprintInput { + return true + } + } + } + } + } + + return false +} diff --git a/yurllib/utils.go b/yurllib/utils.go new file mode 100644 index 0000000..63535e9 --- /dev/null +++ b/yurllib/utils.go @@ -0,0 +1,42 @@ +package yurllib + +import ( + "fmt" + "net/http" + "net/url" +) + +func getDomain(input string) (string, []string) { + + var output []string + + //Clean up domains, removing scheme and path + parsedURL, err := url.Parse(input) + if err != nil { + output = append(output, fmt.Sprintf("The URL failed to parse with error %s \n", err)) + } + + scheme := parsedURL.Scheme + + if scheme != "https" { + output = append(output, fmt.Sprintf("WARNING: The URL must use HTTPS, trying HTTPS instead. \n\n")) + + parsedURL.Scheme = "https" + parsedURL, err = url.Parse(parsedURL.String()) + if err != nil { + output = append(output, fmt.Sprintf("The URL failed to parse with error %s \n", err)) + } + } + + return parsedURL.Host, output +} + +func makeRequest(fileURL string) (*http.Response, error) { + + resp, err := http.Get(fileURL) + if err != nil { + return nil, err + } + + return resp, nil +}