@@ -12,6 +12,7 @@ import {
12
12
SourceCodeType ,
13
13
LiquidCheckDefinition ,
14
14
LiquidHtmlNode ,
15
+ SchemaProp ,
15
16
} from '../../types' ;
16
17
import { isAttr , isValuedHtmlAttribute , isNodeOfType , ValuedHtmlAttribute } from '../utils' ;
17
18
import { last } from '../../utils' ;
@@ -42,15 +43,24 @@ function isLiquidVariable(node: LiquidHtmlNode | string): node is LiquidVariable
42
43
return typeof node !== 'string' && node . type === NodeTypes . LiquidVariable ;
43
44
}
44
45
45
- function isUrlHostedbyShopify ( url : string ) : boolean {
46
- const urlObj = new URL ( url ) ;
47
- return SHOPIFY_CDN_DOMAINS . includes ( urlObj . hostname ) ;
46
+ function isUrlHostedbyShopify ( url : string , allowedDomains : string [ ] = [ ] ) : boolean {
47
+ try {
48
+ const urlObj = new URL ( url ) ;
49
+ return [ ...SHOPIFY_CDN_DOMAINS , ...allowedDomains ] . includes ( urlObj . hostname ) ;
50
+ } catch ( _error ) {
51
+ // Return false for any invalid URLs (missing protocol, malformed URLs, invalid characters etc.)
52
+ // Since we're validating if URLs are Shopify-hosted, any invalid URL should return false
53
+ return false ;
54
+ }
48
55
}
49
56
50
- function valueIsDefinitelyNotShopifyHosted ( attr : ValuedHtmlAttribute ) : boolean {
57
+ function valueIsDefinitelyNotShopifyHosted (
58
+ attr : ValuedHtmlAttribute ,
59
+ allowedDomains : string [ ] = [ ] ,
60
+ ) : boolean {
51
61
return attr . value . some ( ( node ) => {
52
62
if ( node . type === NodeTypes . TextNode && / ^ ( h t t p s ? : ) ? \/ \/ / . test ( node . value ) ) {
53
- if ( ! isUrlHostedbyShopify ( node . value ) ) {
63
+ if ( ! isUrlHostedbyShopify ( node . value , allowedDomains ) ) {
54
64
return true ;
55
65
}
56
66
}
@@ -60,7 +70,7 @@ function valueIsDefinitelyNotShopifyHosted(attr: ValuedHtmlAttribute): boolean {
60
70
if ( isLiquidVariable ( variable ) ) {
61
71
const expression = variable . expression ;
62
72
if ( expression . type === NodeTypes . String && / ^ h t t p s ? : \/ \/ / . test ( expression . value ) ) {
63
- if ( ! isUrlHostedbyShopify ( expression . value ) ) {
73
+ if ( ! isUrlHostedbyShopify ( expression . value , allowedDomains ) ) {
64
74
return true ;
65
75
}
66
76
}
@@ -96,7 +106,27 @@ function valueIsShopifyHosted(attr: ValuedHtmlAttribute): boolean {
96
106
} ) ;
97
107
}
98
108
99
- export const RemoteAsset : LiquidCheckDefinition = {
109
+ // Takes a list of allowed domains, and normalises them into an expected domain: www.domain.com -> domain.com for equality checks.
110
+ function normaliseAllowedDomains ( allowedDomains : string [ ] ) : string [ ] {
111
+ return allowedDomains
112
+ . map ( ( domain ) => {
113
+ try {
114
+ const url = new URL ( domain ) ;
115
+ // Hostname can still return www. from https://www.domain.com we want it to be https://www.domain.com -> domain.com
116
+ return url . hostname . replace ( / ^ w w w \. / , '' ) ;
117
+ } catch ( _error ) {
118
+ // we shouldn't return the malformed domain - should be strict and stick to web standards (new URL validation).
119
+ return undefined ;
120
+ }
121
+ } )
122
+ . filter ( ( domain ) : domain is string => domain !== undefined ) ;
123
+ }
124
+
125
+ const schema = {
126
+ allowedDomains : SchemaProp . array ( SchemaProp . string ( ) ) . optional ( ) ,
127
+ } ;
128
+
129
+ export const RemoteAsset : LiquidCheckDefinition < typeof schema > = {
100
130
meta : {
101
131
code : 'RemoteAsset' ,
102
132
aliases : [ 'AssetUrlFilters' ] ,
@@ -108,11 +138,13 @@ export const RemoteAsset: LiquidCheckDefinition = {
108
138
} ,
109
139
type : SourceCodeType . LiquidHtml ,
110
140
severity : Severity . WARNING ,
111
- schema : { } ,
141
+ schema,
112
142
targets : [ ] ,
113
143
} ,
114
144
115
145
create ( context ) {
146
+ const allowedDomains = normaliseAllowedDomains ( context . settings . allowedDomains || [ ] ) ;
147
+
116
148
function checkHtmlNode ( node : HtmlVoidElement | HtmlRawNode ) {
117
149
if ( ! RESOURCE_TAGS . includes ( node . name ) ) return ;
118
150
@@ -124,11 +156,14 @@ export const RemoteAsset: LiquidCheckDefinition = {
124
156
125
157
const isShopifyUrl = urlAttribute . value
126
158
. filter ( ( node ) : node is TextNode => node . type === NodeTypes . TextNode )
127
- . some ( ( textNode ) => isUrlHostedbyShopify ( textNode . value ) ) ;
159
+ . some ( ( textNode ) => isUrlHostedbyShopify ( textNode . value , allowedDomains ) ) ;
128
160
129
161
if ( isShopifyUrl ) return ;
130
162
131
- const hasDefinitelyARemoteAssetUrl = valueIsDefinitelyNotShopifyHosted ( urlAttribute ) ;
163
+ const hasDefinitelyARemoteAssetUrl = valueIsDefinitelyNotShopifyHosted (
164
+ urlAttribute ,
165
+ allowedDomains ,
166
+ ) ;
132
167
if ( hasDefinitelyARemoteAssetUrl ) {
133
168
context . report ( {
134
169
message : 'Asset should be served by the Shopify CDN for better performance.' ,
@@ -167,7 +202,10 @@ export const RemoteAsset: LiquidCheckDefinition = {
167
202
if ( hasAsset ) return ;
168
203
169
204
const urlNode = parentNode . expression ;
170
- if ( urlNode . type === NodeTypes . String && ! isUrlHostedbyShopify ( urlNode . value ) ) {
205
+ if (
206
+ urlNode . type === NodeTypes . String &&
207
+ ! isUrlHostedbyShopify ( urlNode . value , allowedDomains )
208
+ ) {
171
209
context . report ( {
172
210
message : 'Asset should be served by the Shopify CDN for better performance.' ,
173
211
startIndex : urlNode . position . start ,
0 commit comments