在需要主子域跨域技术的应用场景中,父 frame 和子 frame 设置相同的 document.domain 是一种特别常用的方式,我们可以看见腾讯公司的页面中很多都会有一句:

document.domain = "qq.com";

qq.com 域页面的登录行为很多都是依赖这种方式与iframe结合来实现的。

事实上,W3C 的 DOM 2 HTML标准document.domain定义为只读的:

domain of type DOMString readonly
The domain name of the server that served the document or null if the server cannot be identified by a domain name.

HTML5 草案 中有关于对 document.domain赋值的内容。

WebkitDocument.idl 源码中对 domain 有这样的定义:

#if defined(LANGUAGE_JAVASCRIPT) && LANGUAGE_JAVASCRIPT
    [TreatNullAs=NullString  SetterRaisesException] attribute DOMString domain;
#else
    readonly attribute DOMString domain;
#endif

这也说明了 domain 设置为“writable” 仅用于页面脚本:即允许主子域脚本进行通信,但不涉及 localStorageindexedDBXMLHttpRequest 的共享。目前市场上主流浏览器都支持 domain可写,可以满足几乎所有主子域脚本通信需求,但在特殊情况下也有些许不同。

所有浏览器都不支持设置 domain 为当前域子域或不完整的超域,比如当前域为 “abc.google.com” 那么设置 domain 为 “www.abc.google.com” 或”ogle.com” 都是不允许的。现在测试各个浏览器在 host 为以下情形下设置不同domain的反应:

  • 为IP地址
  • 单节域名
  • 多节域名

测试 host 为 “google”、“www.google.com”、“10.11.202.231”。由子域名向父域名测试,因此前面的测试不会对后面的测试造成干扰。

UA/host google www.google.com 10.11.202.231
Firefox(Mac/Windows/Android) s.ff m.ff ip.ff
Safari(iOS/Mac/Windows) s.safari m.safari ip.safari
IE6~7 s.ie67 m.ie67 ip.ie67
Chrome(Mac Windows)/IE8~10/Opera(presto内核 Mac/Windows) s.chrome-ie810-opera m.chrome-ie810-opera ip.chrome-ie810-opera
IE(WP8) 无法打开 m.chrome-ie810-opera ip.chrome-ie810-opera

由上表可得出以下结论:

  • Firefox 可以接受带 port 的父域名,但是任意 port 都会被忽略,其它浏览器则会报错;
  • 对于 IP 地址,IE6、IE7 和 Safari 简单地将其当做为域名;
  • 仅 Safari 允许将 domain 设置为最后一节域名。

Safari 以及 国内几乎所有带 webkit 内核的浏览器 使用了一种相对简单的方式,即在字符串层面上新的 domain 是当前 domain 的“父域名”即可,可以从 webkitDocument.cpp 文件的源代码中看出:

void Document::setDomain(const String& newDomain  ExceptionCode& ec)
{
if (SchemeRegistry::isDomainRelaxationForbiddenForURLScheme(securityOrigin()->protocol()))     {
        ec = SECURITY_ERR;
        return;
    }

    // Both NS and IE specify that changing the domain is only allowed when
    // the new domain is a suffix of the old domain.

    // FIXME: We should add logging indicating why a domain was not allowed.

    // If the new domain is the same as the old domain  still call
    // securityOrigin()->setDomainForDOM. This will change the
    // security check behavior. For example  if a page loaded on port 8000
    // assigns its current domain using document.domain  the page will
    // allow other pages loaded on different ports in the same domain that
    // have also assigned to access this page.
    if (equalIgnoringCase(domain()  newDomain)) {
        securityOrigin()->setDomainFromDOM(newDomain);
        return;
    }

    int oldLength = domain().length();
    int newLength = newDomain.length();
    // e.g. newDomain = webkit.org (10) and domain() = www.webkit.org (14)
    if (newLength >= oldLength) {
        ec = SECURITY_ERR;
        return;
    }

    String test = domain();
    // Check that it's a subdomain  not e.g. "ebkit.org"
    if (test[oldLength - newLength - 1] != '.') {
        ec = SECURITY_ERR;
        return;
    }

    // Now test is "webkit.org" from domain()
    // and we check that it's the same thing as newDomain
    test.remove(0  oldLength - newLength);
    if (test != newDomain) {
        ec = SECURITY_ERR;
        return;
    }

    securityOrigin()->setDomainFromDOM(newDomain);
}

因此即使是IP地址或是最后一节 domain 也会被允许设置。Internet Explorer 不开源,但可以猜测其对多节域名进行了最后一节域名限制,在 IE8+ 上增加了IP地址限制。Firefox 在3.0版本增加了此限制。对于单节域名如 http://hello/,所有浏览器都一致性地允许设置,当然,这相当于设置 domain 为自身。

Firefox浏览器忽略 port 的行为初衷不得而知,但可以测试该特性是在3.0版本上增加的。

值得一提的是,chromium 项目对 webkit 进行了一些修改,从而带来了一些新的特性,观察 Document.cpp 文件的源代码:

void Document::setDomain(const String& newDomain  ExceptionState& exceptionState)
{
    if (isSandboxed(SandboxDocumentDomain)) {
        exceptionState.throwSecurityError("Assignment is forbidden for sandboxed iframes.");
        return;
    }

if (SchemeRegistry::isDomainRelaxationForbiddenForURLScheme(securityOrigin()->protocol()))     {
    exceptionState.throwSecurityError("Assignment is forbidden for the '" + securityOrigin()->    protocol() + "' scheme.");
        return;
    }

    if (newDomain.isEmpty()) {
        exceptionState.throwSecurityError("'" + newDomain + "' is an empty domain.");
        return;
    }

OriginAccessEntry::IPAddressSetting ipAddressSetting = settings() && settings()->treatIPAddressAsDomain() ? OriginAccessEntry::TreatIPAddressAsDomain :     OriginAccessEntry::TreatIPAddressAsIPAddress;
OriginAccessEntry accessEntry(securityOrigin()->protocol()  newDomain      OriginAccessEntry::AllowSubdomains  ipAddressSetting);
    OriginAccessEntry::MatchResult result = accessEntry.matchesOrigin(*securityOrigin());
    if (result == OriginAccessEntry::DoesNotMatchOrigin) {
    exceptionState.throwSecurityError("'" + newDomain + "' is not a suffix of '" + domain() + "'.    ");
        return;
    }

    if (result == OriginAccessEntry::MatchesOriginButIsPublicSuffix) {
        exceptionState.throwSecurityError("'" + newDomain + "' is a top-level domain.");
        return;
    }

    securityOrigin()->setDomainFromDOM(newDomain);
    if (m_frame)
        m_frame->script().updateSecurityOrigin(securityOrigin());
}

可见除了支持HTML5的 sandbox 之外,还增加了IP地址检测,因此 Chrome 才不会像Safari那样允许IP地址的一段设置为 domain。特别地,Chrome 还进行了顶级域名检测, chromium 项目会生成一个 effective_tld_names.gperf 文件,提供了很多域名列表,末尾标记不为0的域名将不能设置为 document.domain。比如,一个域名为www.english.uk的页面,在 Chrome 下将不能设置 document.domainenglish.uk,因为 english.uk 被认为是顶级域名从而报错:

SecurityError: Failed to set the ‘domain’ property on ‘Document’: ‘english.uk’ is a top-level domain.

这部分逻辑的一些代码来自 Mozilla ,因此Firefox (3.0+)也具有同样的特性。

截止今天(2014-04-24),这个列表至少包含472个顶级域名,包括孟加拉国、文莱、库克群岛、塞浦路斯、厄立特里亚、埃塞俄比亚、斐济、马尔维纳斯群岛、关岛、以色列、牙买加、肯尼亚、柬埔寨、科威特、缅甸、莫桑比克、尼加拉瓜、尼泊尔、新西兰、巴布亚新几内亚、土耳其、英国、也门、南非、赞比亚、津巴布韦等国家和地区的顶级域名。想必这些国家的网站在设置 document.domain 时会遇到一些困难了:)。

在对IE浏览器进行测试时,也发现了一些奇怪的事情。实验“aa.bb.cc.dd”域名,发现在 IE8+ 下将不能设置 document.domain 为“cc.dd”。经过反复测试发现 IE8+在多节域名下允许设置 的双节域名中,两节单词中要至少有一个大于2个字母,换言之,下列域名都是不允许的:

  • sa.cn
  • o.jp
  • x.m

但下列是允许的:

  • sax.cn
  • o.com
  • xxx.k

暂不知微软用意为何,但可以联想到,新浪微博的短域名 t.cn在有下一级域名的情形下,将不能设置 document.domaint.cn

即便拥有上面的诸多问题,不过都属于特例,除了 IE8+ 的短域名问题,其它基本都不会在日常的开发中遇到。

参考
  1. http://javascript.info/tutorial/same-origin-security-policy
  2. http://msdn.microsoft.com/en-us/library/ie/ms533740(v=vs.85).aspx
  3. https://developer.mozilla.org/en-US/docs/Web/API/document.domain

Js中一些本地(native/built-in)对象和方法是可以重写的,比如在针对低版本浏览器的编程中,我们使用:

Array.prototype.forEach = function(item index arr){
    ...
};

来对ES5中才定义的方法增加polyfill。这样可能会带来的一些潜在的隐患问题暂且不表,但我们可以像在现代浏览器中一样安全地使用forEach方法了(只要你实现地足够稳健)。

当在陌生的或不可控的环境中运行JavaScript代码时,你可能不相信可能被 ‘polyfill’ 过的方法,比如Array.prototype.everyArray.prototype.mapwindow.Promise。这个时候,你需要识别你要使用的方法是一个 JavaScript 宿主环境的本地方法还是一个已经由别人 ‘polyfill’ 过的方法。

不过似乎没有任何标准或草案规定自定义方法和 native 方法应该在行为上表现出任何不同。但可以想象,JavaScript 引擎一般由C++语言编写,似乎 native 方法无法打印出其源代码。

默认情形下,将一个函数对象转成String类型即可输出其源代码,同样将方法名作为参数传入RegExp.prototype.test也会先转成String,John Resig 写的js简单继承实现中即使用这种方式来识别_super单词的。因此对于自定义方法,会输出源码,那么对于 native 方法,目前主流浏览器很一致性地输出类似function func_name() { [native code] }的字符串,甚至nodejs也同样,具体回车换行各种环境实现有略微差异,Javascript 实现的 DOM 选择器sizzle总结了一个通用的正则表达式:

/^[^{]+\{\s*\[native \w/

。通过对方法源码进行正则匹配来识别是本地方法与否。

这种通过源码进行识别的方式有一个问题,可以查看Ecma 262对于Funtion.prototype.toString的定义:

An implementation-dependent representation of the function is returned. This representation has the syntax of a FunctionDeclaration. Note in particular that the use and placement of white space line terminators and semicolons within the representation String is implementation-dependent.

也就是说并未针对native方法做了特殊规定,甚至没有规定方法默认应该输出为其源码。在将来的实现中,对于native方法的默认字符串表示可能出现变化,特别是Google的V8引擎中越来越多地直接使用JavaScript语言本身来编写。

另一方面,这种方式强依赖于toString方法的默认行为,因此重写toString可以达到欺骗的效果,如:

var s = Array.prototype.every = function(){
    return !!'this is a fake forEach!';
    };

s.toString = function(){
    return 'function every(){[native code]}';
};

//true but it shouldn't
/^[^{]+\{\s*\[native \w/.test(s);

一个自定义方法通过了检测。虽然这是一个极端的例子,但对于未有明确标准定义的,特别是将来有可能发生行为改变的内容,最好的使用方式就是不依赖于这种非标准的,不稳定的特性。

这里有一篇比较老的文章,其内容揭示了确实有老版本的Safari浏览器无法通过上面的正则表达式检测。

0%