前幾天介紹了如何利用toDataURL()將canvas繪製結果轉為圖檔的做法,但實際應用時,卻發現常常會冒出奇怪的錯誤:

  • 在IE, Chrome或Safari上出現: SECURITY_ERR: DOM Exception 18
  • 在FireFox則是冒出: 0x805303e8 (NS_ERROR_DOM_SECURITY_ERR)

原來,跟Cross-Site Scripting的限制一樣,HTML canvas也有其安全原則! 簡單來說,可想成每個canvas有個origin-clean旗標,一開始預設為true,一旦有下列任一情況發生時,origin-clean旗標即被設為false:

  1. drawImage()時,使用與document不同來源(origin,跟瀏覽器跨網域議題裡網域的概念差不多)的image或video作為材料
  2. drawImage()時,使用orgin-clean=false的canvas作為材料
  3. fillStyle使用其他來源的image或video作為pattern
  4. fillStyle使用其他orgin-clean=false的canvas建立的pattern
  5. strokeStyle使用其他來源的image或video作為pattern
  6. strokeStyle使用其他orgin-clean=false的canvas建立的pattern
  7. fillText()或strokeText()使用其他來源的字型

一旦canvas的orgin-clean旗標被設為false,此時若呼叫toDataURL()、getDataImage()或measureText()等方法,都會引發安全性錯誤[DOMException.SECURITY_ERR (18)]! 最簡單的例子,就是網站A網頁上的canvas,在drawImage時,引用了放在網站B的某個圖檔當作<img> src,則該canvas就無法再執行toDataURL()了!

以下是一個重現問題的範例:

    <!DOCTYPE html>
     
    <html>
    <head>
        <title>測試Canvas來源安全原則</title>
        <script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.6.4.js">
        </script>
        <script>
            $(function () {
                var canvas = document.getElementById("c");
                var ctx = canvas.getContext("2d");
                $("#b1").click(function () { //畫上漸層線
                    ctx.lineWidth = 20;
                    for (var i = 0; i < 10; i++) {
                        ctx.strokeStyle = "rgb(0,0," + i * 25 + ")";
                        ctx.beginPath();
                        ctx.moveTo(0, i * 10);
                        ctx.lineTo(200, i * 10);
                        ctx.stroke();
                    }
                });
                $("#b2").click(function () { //由同網站取得圖檔
                    var $img = $("<img />", { src: "DarkthreadPlurk.jpg" });
                    $img.load(function () {
                        ctx.drawImage(this, 5, 5, 90, 90);
                    });
                });
                $("#b3").click(function () { //由其他站台取得圖檔
                    var $img = $("<img />", { 
                        src: "http://avatars.plurk.com/3405911-big3.jpg"
                    });
                    $img.load(function () {
                        ctx.drawImage(this, 105, 5, 90, 90);
                    });
                });
                $("#b4").click(function () { //使用toDataURL()匯出圖檔
                    try {
                        $("#p").attr("src", canvas.toDataURL());
                    }
                    catch (err) {
                        alert(err.toString());
                    }
                });
                $("#b5").click(function () { //使用getImageData()取得像素資料
                    try {
                        var id = ctx.getImageData(0, 0, 1, 1);
                        alert(id.data.length);
                    }
                    catch (err) {
                        alert(err.toString());
                    }
                });
            });
        </script>
        <style>
            .std { border: 1px solid black; display: block; margin: 5px; }
        </style>
    </head>
    <body>
    <input type="button" id="b1" value="Draw Lines" />
    <input type="button" id="b2" value="Load Local Image" />
    <input type="button" id="b3" value="Load Remote Image" />
    <canvas id="c" class="std" width="200" height="100"></canvas>
    <input type="button" id="b4" value="Export Image" />
    <input type="button" id="b5" value="Export Pixel Array" />
    <img id="p" class="std" />
    </body>
    </html>

    網頁上有一個canvas,按下b1鈕(Draw Lines)會在canvas上塗滿漸層橫條、按下b2(Load Local Image)會由同一網站載入圖檔,以drawImage()繪製在左半邊、按下b3(Load Remote Image)則會由Plurk網站取得同樣的圖檔顯示在右半邊。

    按下b4(Export Image)鈕會呼叫canvas.toDataURL()將canvas的內容輸出在下方 的<img>,按下b5(Export Pixel Array)則會呼叫context.getImageData(0, 0, 1, 1)取得最左上角的像素數據alert()出來。

    測試時,若只按下b1或b2,尚可順利執行toDataURL()或getImageData(),一旦按下b3由外部網站取得圖檔加入canvas,之後再試著按b4或b5都會引發如上圖的錯誤!!

    既然是瀏覽器基於安全性考量設立的限制,一般來說就不太有閃躲迴避的空間,我想到比較簡單直覺的繞路法是在同一網站上用ASP.NET或其他伺服器端語言寫一小段程式,以伺服器身分去遠端網站取回圖檔內容,再將byte[]抛回給瀏覽器,喬裝圖檔來自同一網站。

    以下是一個下載轉接範例(ImageProxy.ashx):
    資安提醒: 直覺上寫成用QueryString接入外部圖檔URL參數,再以BinaryWrite方式直接將圖檔內容傳回是最簡便,不過這將衍生類似XSS的風險(參考),若未再加其他限制,可能會被其他網站或惡意程式當作下載檔案的跳板(用來對圖檔來源網站隱藏瀏覽器直實IP或突破存取限制),故設定限定POST方式又只回傳Data URI字串,可降低部分被盜用的風險。但是,即便限定POST,這裡示範的做法仍可能讓使用者有機會接觸到原本只有伺服器可以存取的內部網站資源(前題是攻擊者已掌握內部資源的URL),務必留意此一層面的資安風險,若要應用在實務環境中,建議再對其可下載網段加入限制並保留存取記錄。

    <%@ WebHandler Language="C#" Class="ImageProxy" %>
     
    using System;
    using System.Web;
    using System.Net;
     
    public class ImageProxy : IHttpHandler {
        
        public void ProcessRequest (HttpContext context) {
            HttpRequest req = context.Request;
            HttpResponse resp = context.Response;
            try
            {
                //限定透過POST,避免被其他網站當作下載跳板
                if (req.HttpMethod == "POST")
                {
                    string url = req.Form["url"];
                    if (string.IsNullOrEmpty(url)) return;
                    string mime = "";
                    switch (url.Substring(url.Length - 4, 4).ToLower())
                    {
                        case ".jpg":
                            mime = "image/jpeg";
                            break;
                        case ".png":
                            mime = "image/png";
                            break;
                        case ".gif":
                            mime = "image/gif";
                            break;
                        default:
                            return;
                    }
                    WebClient wc = new WebClient();
                    byte[] b = wc.DownloadData(url);
                    resp.Write(string.Format("data:{0};base64,{1}",
                                        mime, Convert.ToBase64String(b)));
                }
            }
            catch (Exception ex)
            {
                resp.ContentType = "text/plain";
                resp.Write(ex.Message);
            }
                
        }
     
        public bool IsReusable {
            get {
                return false;
            }
        }
     
    }

    前端只需小幅修改:

        $("#b3").click(function () { //透過Proxy ASP.NET由其他站台取回圖檔
            $.post("ImageProxy.ashx", {
                url: "http://avatars.plurk.com/3405911-big3.jpg"
            }, function (r) {
                if (r.indexOf("data:") != 0) alert(r);
                else {
                    var $img = $("<img />", { src: r });
                    $img.load(function () {
                        ctx.drawImage(this, 105, 5, 90, 90);
                    });
                }
            });
        });

    如此便可透過ImageProxy.ashx取回其他網站的圖檔內容,而不造成origin-clear=false。但再次提醒,ImageProxy的做法有其資安風險,如無法評估其危害程度,建議不要使用。


    Comments

    Be the first to post a comment

    Post a comment


    54 - 6 =