原來判斷字串是不是 URL 超難 - 談 Evil Regexes

紅寶鐵軌客
Join to follow...
Follow/Unfollow Writer: 紅寶鐵軌客
By following, you’ll receive notifications when this author publishes new articles.
Don't wait! Sign up to follow this writer.
WriterShelf is a privacy-oriented writing platform. Unleash the power of your voice. It's free!
Sign up. Join WriterShelf now! Already a member. Login to WriterShelf.
寫程式中、折磨中、享受中 ......
849   0  
·
2019/10/06
·
7 mins read


很多事情,真的是不遇到不知道,寫個電腦程式也真的是太難了。

題目:需求超簡單,給一個字串,判斷是不是合法的 URL,傳回真假。 

用 RegExp,竟然有可能超慢,我還以為當機了

第一個直覺就是用 RegExp 寫,比較字串,就是 RegExp 的本業,剛好我的需求是在前端,就用 JavaScript 寫了一個,只是寫好後,問題不斷,原來要判斷一個字串是不是 URL,原來那麼的難。

用 RegExp 的第一個問題就是中文判斷,這個問題還好,網路有高人,原來的 RegExp 加上就好了,只是很不好加,RegExp 寫在 JavaScript 上,只能說好像在寫火星文,一堆反斜線,寫得好累,各位如果有興趣,我是參考這篇

接下來的問題就大了,也學到一件事,我發現在長字串的情形下,RegExp 有可能會超級慢,慢到你以為電腦當機了,來,看碼:

var pattern = new RegExp(
  '^(([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}','i'
); 

16:32:02.780 pattern
16:32:02.784 /^(([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}/i
16:32:16.535 pattern.test('query')
16:32:16.539 false
16:32:29.069 pattern.test('query-b179323bc4d3f0cf677118ed5fb76028')
16:36:04.532 false

上面的程式夠簡單吧,就只是判斷 URL 的 host 而已,這是我在 Chrome console 上的測試的結果,第一個 patter.test('query') 只花了不到 0.004 秒,很好,但是可怕的是第二個,patter.test('query-b17.....')竟然花了 5 分 5 秒,而且在這個 5 分 5 秒中,Chrome 是完全沒有反應的,剛開始我完全沒有預期到這樣的問題,直覺上,我以爲電腦當機了,各位看到的這串碼已經是我用最土的 divide and conquer 除錯法找出的問題點,怎麼看都沒有問題,但是,它卻有著大大的問題,也托這個問題的福,我學到了 RegExp 中更深一層的問題,這是一個典型的「毀滅性回尋(Catastrophic Backtracking)」,這是我的翻譯,更多人就直接用 Evil(惡魔)Regexes 來稱呼了。

惡魔 Evil Regexes 簡單來說就是配對時,RegExp 開始不斷的重複找尋,而且這個問題更進一步的延伸成一種攻擊:regular expression denial of service,ReDoS - Wikipedia

惡魔 Evil Regexes 有點難介紹,網路有人介紹的很好,我就不寫了。要更了解,可以先看這篇:https://stackoverflow.com/questions/12841970/how-can-i-recognize-an-evil-regex/ ,寫的簡單好懂,如果要看長篇大論,這篇不錯:Runaway Regular Expressions: Catastrophic Backtracking

好了,重點來了,這個問題非常難解,除非你能限制輸入的字串,只要用 RegExp 你早晚會遇到,唯一能做的就是,小心小心再小心的處理 * 及 +,再多多測試,如果不能接受這種風險,就只能放棄用 RegExp 判斷字串了。

題外話

這讓我想到大學上 Compiler 課時老師所教的 Pumping Lemma,這是定義 Regular Languages 中很重要的基礎理論,開發 Compiler 時,用來判斷新設計的電腦語言中有沒有 ambiguity 的關鍵理論,當時就被 Pumping Lemma 搞得很昏,沒想到多年以後,這個 RegExp 更難相處。 記得以前有個教授說,電腦不用學,因為它只會越來越簡單使用,這句話對了一半,用的人是越來越簡單用了,但是寫程式的人可是越來越難啊。  話說,抓這隻蟲時,又讓我想到我多年前老闆的名言:「程式寫的好,是不需要 debug 的。」我很遜,我到現在程式都寫不好。 

 

用 Server 讀取判斷,有實務困難

RegExp 有危機,那就換用 AJAX 呼叫 Server 用 cURL 或是 NET HTTP 來做實際的 HTTP URL 確定,只要取得 HEAD 就可以判斷 URL 是正確的,多簡單啊,正準備動手寫時,還好先休息了一下,才想到這也不是一個好方法,各位想也知道,會要判斷字串是不是合法的 URL,接下來的動作八九不離十,就是要讀取這個網址,這樣就會照成連續讀取這個 Server,通常,Server 都會有保護機制,連續讀取幾次,很容易就會被判斷成惡意攻擊,我自己的網站都會做判斷,大型網站更是嚴格,而且一旦被列入黑名單,那就不只是 debug 的問題了,要改正,更難,此路不通啊。

 

用 JavaScript API 或是 DOM 一定要有 protocol

又回到網路找救兵後,發現這個問題其實已經在 StackOverflow 上討論的沸沸揚揚,甚至已經被鎖文規定一定要有 10 點積分的才可以發言,太有趣了,原來大家都有這樣的問題,好,那既然 RegExp 不好用,Server 實際讀取也不實際,我們來看看這則沸沸揚揚的 StackOverflow 上的討輪提供了那些其他選項:

https://stackoverflow.com/questions/5717093/check-if-a-javascript-string-is-a-url/49849482

我對其中的兩個選項很感到興趣,一個是用 Dom Parser,另一個是用 URL API,我就來試試兩者有何好壞不同。

比較 Dom Parser 與 URL Constructor

用 Dom Parser ,就先建立一個 anchor 後,只要設定它的 href 值後,是不是 URL 就可以用 host 讀出來,如果是 URL,host 就會應相對應的 host name,如果不是,就回應現在的瀏覽器所在的 host name,或是空字串。

用 URL Constructor 就直接 new 一個 URL 就好了,沒有錯,就是對的 URL,來試試,這麼簡單,就直接用 Chrome console 來試就好:

var a = document.createElement('a');

// test 1
s="http://123"
new window.URL(s)
> URL {href: "http://0.0.0.123/", origin: "http://0.0.0.123", protocol: "http:", username: "", password: "", …}
a.href = s
a.host 
>"0.0.0.123"

// test 2
s="chrome://extensions"
new window.URL(s)
> URL {href: "chrome://extensions/", origin: "chrome://extensions", protocol: "chrome:", username: "", password: "", …}
a.href = s
a.host 
> "extensions"

// test 3
s="webdevelopment"
new window.URL(s)
> Uncaught TypeError: Failed to construct 'URL': ...
a.href = s
a.host 
> "chromewebdata"

// test 4
s="192.168.0.1"
new window.URL(s)
> Uncaught TypeError: Failed to construct 'URL': ...
a.href = s
a.host 
> "chromewebdata"

// test 5
s="http://192.168.0.1"
new window.URL(s)
> URL {href: "http://192.168.0.1/", origin: "http://192.168.0.1", protocol: "http:", username: "", password: "", …}
a.href = s
a.host 
> "192.168.0.1"

// test 6
s="<div> hello world </div>"
new window.URL(s)
> Uncaught TypeError: Failed to construct 'URL': ...
a.href = s
a.host 
> "chromewebdata"

// test 7
s="http::www.site.com"
new window.URL(s)
> Uncaught TypeError: Failed to construct 'URL': ...
a.href = s
a.host 
> ":0"

// test 8
s="hello.world.com"
new window.URL(s)
> Uncaught TypeError: Failed to construct 'URL': ...
a.href = s
a.host 
> "chromewebdata"

// test 9
s="https://" + "hello.world.com"
new window.URL(s)
> URL {href: "https://hello.world.com/", origin: "https://hello.world.com", protocol: "https:", username: "", password: "", …}
a.href = s
a.host 
> "hello.world.com"

// test 10
s="https://" + "hello world com"
new window.URL(s)
> URL {href: "https://hello%20world%20com/", origin: "https://hello%20world%20com", protocol: "https:", username: "", password: "", …}
a.href = s
a.host 
> "hello%20world%20com"

// test 11
s="hello world com"
new window.URL(s)
> Uncaught TypeError: Failed to construct 'URL': ...
a.href = s
a.host 
> "chromewebdata"

其實兩者的測試結果幾乎相同,很不妙的是,如果開頭沒有 http 就一定不是被認定是 URL,在定義上來說,這是對的,只是在實務上,好像可以在變通一下,對付它有一個簡單的方法,就是將 “http://" 先加上後再做測試,只是.......不行,讓我們看看這些測試中,那些是最有問題的:

  • Test 7: 不完整的 “http::www.site.com" 測試中,URL() 是對的,Dom Parser 的 host name 卻回應了一個":0",Dom Parser 是錯的,所以,我會建議用 URL Constructor
  • Test 10: 含有空白的字串,加上"https://" 後,就會被認成合法的 URL 了,這很糟糕,所以不能用我前面所想的簡單方法,不能將 “http://" 先加上後再試,這樣所有的字串就都會是合法的網址了。

所以,如果你要測試的可能 URL 字串是不含 http(s):// 的,這方法不能用,天啊,怎麼測試一個字串是不是 URL 那麼難啊!

 

用 match,就這個吧

我真的沒輒了,最後用的是這個方法,也是在這篇沸沸揚揚的 StackOverflow 討論中 copy 下來的,不完美,但是我真的也想不到更好的方法了:

s="https://www.yahoo.com/happy"
a=s.match(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g);
a!==null
> true

s="name@gmail.com"
a=s.match(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g);
a!==null
> true

s="http://192.168.0.1"
a=s.match(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g);
a!==null
> false

s="192.168.0.1"
a=s.match(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g);
a!==null
> false

s = "query-b179323bc4d3f0cf677118ed5fb76028"
00:53:35.438"
a=s.match(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g);
a!==null
> false

s="www.google.com/123"
a=s.match(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g);
a!==null
> true

最大的問題就是會把 email 當成合法的網址,ip 也不能測試,不過它沒有 Evil(惡魔)Regexes 的「慢」問題,也可以測試不含 http(s):// 的 URL 字串,已知的問題就來給他後面避開,畢竟這應該是我已知的最好解法了。

哎,很多事情,真的是不遇到不知道,原來那麼難。

 


WriterShelf™ is a unique multiple pen name blogging and forum platform. Protect relationships and your privacy. Take your writing in new directions. ** Join WriterShelf**
WriterShelf™ is an open writing platform. The views, information and opinions in this article are those of the author.


Article info

Categories:
Tags:
Date:
Published: 2019/10/06 - Updated: 2019/10/06
Total: 2052 words


Share this article:
About the Author

很久以前就是個「寫程式的」,其實,什麼程式都不熟⋯⋯
就,這會一點點,那會一點點⋯⋯




Join the discussion now!
Don't wait! Sign up to join the discussion.
WriterShelf is a privacy-oriented writing platform. Unleash the power of your voice. It's free!
Sign up. Join WriterShelf now! Already a member. Login to WriterShelf.