chromium

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars233
mentions14
0481216Oct 5Oct 19Nov 3
UTC days · repositories chromium owns5 repositories

Package downloads

No package download history available yet.

Top repositories

chromium’s repositories ranked by new forks, as on trending.

Loading

Contributes to

Repositories outside chromium’s account where chromium opened or reviewed pull requests in the past 365 days.

Who noticed chromium

Everyone who starred or forked chromium’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

14posts
Nov 40 posts
  1. JavaScriptのdocumentをインスタンス化せずに使える理由とDOMの実体を調べてみた はじめに Reactの学習でJavaScriptを書いていたときに document が出てきました。そういえば、自分でインスタンス化していないのに、なぜメソッドを使えるのだろうと気になりました。そこで、Documentオブジェクトがどこで定義されていて、いつインスタンス化されているのか、そもそもDOMとは何かを調べてみました。macOSとChromeを前提にしています。 結論 DocumentオブジェクトのクラスはChromeのソースコードで定義されていて、インスタンスはChromeがページを開くたびに作っています。作られたインスタンスは window.document としてグローバルに置かれるため、自分で new しなくても document のメソッドを使えます。 また、DOM(Document Object Model)は、HTMLをオブジェクトの木構造として表し、JavaScriptから操作できるようにする仕組みのことです。(参考)Chromiumのソースコードにも dom というフォルダがありました。 根拠 1. DocumentクラスはChromeが用意したものか 関数(クラス)の toString() は、その関数のソースコードを文字列で返します。ただし、ブラウザやJavaScriptエンジンが最初から用意している関数は、返せるJavaScriptのソースコードが無いため、代わりに [native code] と表示されます。(参考) Document で [native code] が表示されるか確認します。 Document.toString() // 出力:'function Document() { [native code] }' これで分かるのは、ページのJavaScriptではなくブラウザ側が用意したものだという点までです。何の言語で書かれているかは、5で確認します。 2. document は Document クラスのインスタンスか instanceof で、document が Document クラスのインスタンスか確かめます。 インスタンスの場合、true が出力されます。 document instanceof Document // 出力:true 3. ページを開くたびにDocumentクラスのインスタンスを作っているか 再読み込みすると、それまでの変数も全部消えるため、前後の document を直接比べることはできません。そこで、document に独自のプロパティを追加して文字列を入れておき、再読み込み後にそのプロパティが undefined になることを確認します。 document.myMark = "before" document.myMark // 出力:'before' // 再読み込み document.myMark // 出力:undefined document instanceof Document // 出力:true 再読み込み後の document には目印が無いので、前とは別のオブジェクトです。それでも Document クラスのインスタンスなので、読み込みのたびに新しいインスタンスが作られていると分かります。 4. window.document がグローバルに置かれているか 1行目で、window がグローバルオブジェクト(どこからでも名前だけで呼べる変数の置き場)であることを確認します。2行目で、名前だけで書いた document が window.document と同じものであることを確認します。 globalThis === window // 出力:true window.document === document // 出力:true 5. documentとDOMはどこで見られるか PCにインストールされているChromeは、Googleが配布前にコンパイル(機械語に変換)したものなので、中のソースコードは見られません。ですが、ChromeはオープンソースのChromiumをベースに作られているので、Chromiumのリポジトリで確認できます。 Documentの定義には、Web IDL と C++ が使われています。 :::note info Web IDL とは IDL は Interface Definition Language(インターフェース定義言語)の略です。処理は書かず、JavaScriptからどんな名前・型で使えるかだけを宣言する言語で、拡張子は .idl です。 C++ の拡張子 .h はクラスなどの宣言を書くファイルの拡張子で、.cc はその宣言をincludeして処理の中身を書くファイルの拡張子です。 ::: Chromiumのリポジトリ github.com/chromium/chromium/tree/main DOM周りのフォルダ構成 github.com/chromium/chromium/tree/main/third_part… Documentオブジェクトの定義ファイル github.com/chromium/chromium/blob/main/third_part… github.com/chromium/chromium/blob/main/third_part… github.com/chromium/chromium/blob/main/third_part… さいごに これ以上調べ続けると沼にハマりそうだったので、ここで止めます。 ここまででも、気になっていた点は解消されました。ChromeがChromiumをベースに開発されていることや、Web IDL と C++ で書かれていることも知れて、documentやDOMの理解が少し深まって良かったと感じています。

    3 likes · 2 comments
  2. 7 of My Extensions May Read Every Site, Withholding Defaults to Off I have written about browser extension permissions on this blog before. I walked through how to audit them step by step, and I covered the risk of an extension changing hands after you install it. Both pieces were advice. This time I took my own advice on my own machine, and instead of looking where the panel points, I looked at the file where Chrome actually writes permissions down. Panels show summaries; files keep books. When I counted my notification permissions from the file rather than the panel I saw how far apart those two numbers can drift. My curiosity about extensions was the same: what does each one ask for, what did Chrome actually give it, and is there a gap between the two that I never noticed? The short answer: in host permissions there is no gap — everything asked for was granted. In API permissions I found exactly one difference across nine extensions: one permission, on one extension. The mechanism where a gap could live is there; on my machine it has barely been used. The measurement was taken on 29 October 2026, on macOS, with Chrome 156.0.8078.48. The ledger lives in the profile folder, not the panel Chrome keeps extension records in a file called Secure Preferences inside the profile folder — plain JSON, signed but not encrypted: import json, os P = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Secure Preferences") st = json.load(open(P, encoding="utf-8"))["extensions"]["settings"] print("records:", len(st)) print("with a manifest:", sum(1 for v in st.values() if v.get("manifest"))) My number came back as 22. Not all of these records are listed in the extension panel — Chromium's own rule keeps some of them out of the management UI, and we will look at that rule shortly. Seventeen of the 22 have a manifest embedded, meaning Chrome knows what that extension asks for. The remaining five records have no manifest at all. I opened them to see what was inside: {"app_launcher_ordinal": "w", "lastpingday": "13410432000888874", "page_ordinal": "n"} {"app_launcher_ordinal": "n", "incognito": true, "lastpingday": "13410432000888874", "page_ordinal": "n"} {"active_bit": false, "allowlist": 1, "lastpingday": "13437644400646797"} {"active_bit": false, "allowlist": 2, "disable_reasons": [1], "lastpingday": "13437644400646797"} {} The fifth is an entirely empty dictionary. The second still remembers an incognito permission, and the last still carries the record that says "the user disabled this" — neither has a manifest. Since I called these "no longer installed", let me show it. I compared the five IDs against the extension directories on disk: import json, os B = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default") st = json.load(open(os.path.join(B, "Secure Preferences"), encoding="utf-8"))["extensions"]["settings"] on_disk = set(os.listdir(os.path.join(B, "Extensions"))) no_manifest = [k for k, v in st.items() if not v.get("manifest")] print("records without a manifest:", len(no_manifest)) print("of those, present on disk:", [k for k in no_manifest if k in on_disk]) records without a manifest: 5 of those, present on disk: [] All five are gone from disk. The ledger keeps what it remembers about things that are no longer installed — the same pattern I found when I counted notification permissions: these files behave like append-only books. Lines get added, never removed. Which ones are mine, and which ones are Chrome itself Not all 17 records with a manifest are software I installed. The location field makes the distinction, and that field is an enum in Chromium: in extensions/common/mojom/manifest.mojom it is defined as kInternal = 1, kExternalPref = 2, kComponent = 5, kExternalPrefDownload = 6, kExternalComponent = 10. The enum's own comment describes kComponent as an integral component of Chrome itself that happens to be implemented as an extension — and says these are not shown in the management UI. My distribution: 1 → 7 records, 2 → 1, 5 → 7, 6 → 1, 10 → 1. So 8 of the 17 records are Chrome's own internals (the PDF viewer, text-to-speech, the Web Store and so on). That leaves 9 extensions installed on the user side. Per the enum's comment, this is also the group the panel lists; components do not appear there. Two of them do not have location 1: one is 2, the other is 6. The enum does not treat these as the same thing: kExternalPref = 2 is "a crx file from an external directory (via prefs)", while kExternalPrefDownload = 6 is the same thing but "installed from an update URL". The difference is visible on disk: ls ~/Library/Application\ Support/Google/Chrome/External\ Extensions/ cat ~/Library/Application\ Support/Google/Chrome/External\ Extensions/*.json fcoeoabgfenejglbffodgkkbkcdhcgfn.json fldmhceldgbpfpkbgopacenieobmligc.crx fldmhceldgbpfpkbgopacenieobmligc.json {"external_update_url": "https://clients2.google.com/service/update2/crx"} {"external_crx": ".../External Extensions/fldmhceldgbpfpkbgopacenieobmligc.crx", "external_version": "2.0.23"} Both arrived because a desktop application registered itself with Chrome. But one told Chrome "download it from this URL", and the other dropped a 2.9 MB CRX straight onto the disk for Chrome to load from the file. There is a reflex of mine that needs correcting here. Both records say from_webstore: true. Reading that flag as "so it came from the store" would be wrong: the extension loaded from a local CRX carries the same true. The origin is not in the flag, it is in those two-line JSON files in External Extensions. When you install a desktop application, a quiet housemate moves into your browser too. The lease is not in the panel either; it is in that folder. Declared permissions and granted permissions are not the same thing Chrome keeps three separate permission sets per extension. The constants in extensions/browser/extension_prefs.cc, and the comments above them, separate the three clearly: - active_permissions — curiously, the name in the code is kPrefDesiredActivePermissions. Its comment: the set of permissions the extension desires to have active, which may include more than the manifest requires if the extension has optional permissions. - granted_permissions — its comment: the set the user has approved either at install time or through an optional permissions request. Chromium also states why it tracks this: in order to alert the user of permissions escalation. - runtime_granted_permissions — permissions granted at runtime rather than at install time. Alongside these sits a single boolean: withholding_permissions. The code's comment explains it as a preference indicating whether requested host permissions are being withheld from the extension, requiring them to be granted through the permissions API or runtime host permissions. That boolean, sitting between declaration and consent, was the thing I wanted to measure. Nine extensions, seven "all sites" Here is the script that produces the table. It takes the records whose location is 1, 2 or 6, counts the broad patterns the manifest asks for (<all_urls>, *://*/*, http://*/*, https://*/*), and checks whether those same patterns also appear in granted_permissions: import json, os P = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Secure Preferences") st = json.load(open(P, encoding="utf-8"))["extensions"]["settings"] BROAD = {"<all_urls>", "*://*/*", "http://*/*", "https://*/*"} def has(d, k): return bool(BROAD & set((d or {}).get(k) or [])) n = b = g = w = dbg = 0 for eid, v in sorted(st.items(), key=lambda x: (x[1].get("manifest") or {}).get("name", "")): m = v.get("manifest") if not m or v.get("location") not in (1, 2, 6): continue decl = has(m, "host_permissions") or bool(BROAD & { x for cs in (m.get("content_scripts") or []) for x in (cs.get("matches") or [])}) grant = has(v.get("granted_permissions"), "explicit_host") or \ has(v.get("granted_permissions"), "scriptable_host") kis = v.get("withholding_permissions") d = "debugger" in (m.get("permissions") or []) n, b, g, w, dbg = n + 1, b + decl, g + grant, w + bool(kis), dbg + d print(f"{m.get('name','?')[:34]:34s} loc={v.get('location')} declared={decl} " f"granted={grant} withheld={kis} debugger={d}") print(f"\nextensions={n} broad_declared={b} broad_granted={g} withholding_on={w} debugger={dbg}") The output: AdGuard Reklam Engelleyici loc=1 declared=True granted=True withheld=False debugger=False ChatGPT loc=1 declared=True granted=True withheld=False debugger=True Claude loc=6 declared=True granted=True withheld=False debugger=True DEPRECATED Secure Shell App loc=1 declared=False granted=False withheld=False debugger=False IP Address and Domain Information loc=1 declared=False granted=False withheld=False debugger=False Kimi loc=2 declared=True granted=True withheld=False debugger=True React Developer Tools loc=1 declared=True granted=True withheld=False debugger=False [password manager — redacted] loc=1 declared=True granted=True withheld=False debugger=False User-Agent Switcher for Chrome loc=1 declared=True granted=True withheld=False debugger=False extensions=9 broad_declared=7 broad_granted=7 withholding_on=0 debugger=3 (The output is verbatim with exactly one change: I redacted my password manager's product name while pasting, and left the redaction visible in the row. The other extensions request the same permissions on every machine they are installed on, so there is nothing to hide there; which vault product I use is nobody's business.) The totals: 9 user extensions, 7 declare broad host access, 7 were granted it, and 0 have withholding enabled. withholding_permissions is false in all nine records. One caveat: seven having the permission is not the same as seven running. I will come back to the User-Agent Switcher row below — that extension is disabled, but its grant is still in the ledger. The seven in the title counts permissions, not processes. One thing deserves to be said plainly: the declared and granted columns matching is not a bug, it is the design. I was the one who saw the permission screen at install time, and I was the one who clicked "Add". What makes me think is the third column. Chrome has a setting that lets me say "only run this extension when I click it", and I have not used it on a single one of the nine. Two separate doors: explicit_host and scriptable_host When the script above says "broad access" it checks two separate fields, because Chrome keeps two kinds of host permission apart. The field names in extension_prefs.cc are kPrefExplicitHosts → explicit_host and kPrefScriptableHosts → scriptable_host. Roughly: the first is the extension being able to reach that address over the network, the second is being able to inject scripts into it. To make the distinction concrete, I counted the approved patterns in each field: AdGuard Reklam Engelleyici explicit=1 scriptable=8 ChatGPT explicit=1 scriptable=1 Claude explicit=1 scriptable=3 DEPRECATED Secure Shell App explicit=0 scriptable=0 IP Address and Domain Informat explicit=1 scriptable=0 Kimi explicit=1 scriptable=1 React Developer Tools explicit=1 scriptable=1 [password manager — redacted] explicit=1 scriptable=1 User-Agent Switcher for Chrome explicit=1 scriptable=0 The ad blocker has 8 scriptable patterns: http://*/* and https://*/* are both there, and the remaining six are specific pages on its own domains. The User-Agent Switcher is explicit=1, scriptable=0 — it reaches every address but puts no script on the page, because its job is to rewrite requests, not to touch the document. This distinction is worth paying attention to. "It runs on every site" can describe two different things, and the two do not carry the same risk. If you are deciding based on a single label, you do not know which of these two columns you are reading. The default is written in the code, not the documentation So why is withholding_permissions false? Because I never touched it, or because that is what gets written at install time? Those are not the same thing: the first is "no setting", the second is "there is a setting and it is off". I checked the official documentation. Neither the permission-declaration page nor the permissions-list page on developer.chrome.com states the default for a plain host_permissions entry. The only explicit statement about timing is on the optional_host_permissions line: granted by the user at runtime, instead of at install time. MDN's host_permissions page says that most browsers treat host_permissions as optional and that users can grant or revoke them on an ad hoc basis — but that sentence is about browsers in general, not Chrome specifically. The answer is in the code. The install-time path in extension_prefs.cc does this: if permissions can be withheld from the extension and no special withholding creation flag was given at install, it writes the default value into the preference. That default is one line in the same file: // The default value to use for permission withholding when setting the pref on // installation or for extensions where the pref has not been set. constexpr bool kDefaultWithholdingBehavior = false; So the false values on my machine are not a preference of mine. They are a default written to disk at install time. The documentation does not say this; the source does. That is not a contradiction — the docs say nothing wrong, they simply never write this line down. !Diagram Permissions cannot be withheld from Chrome's own parts The left branch of that diagram is not a guess; my own data confirms it. CanWithholdPermissionsFromExtension in extension_util.cc combines four conditions: the extension must be displayed in extension settings, must not come from a policy location, must not come from a component location, and must not be allowlisted to script everywhere. The comment above the function gives the reason outright: some extensions must retain privilege to all requested host permissions, because withheld permissions could not be granted for an extension that does not show up in chrome://extensions. The definition of "component" is one line in manifest.h: IsComponentLocation means kComponent (5) or kExternalComponent (10). Now go back to the measurement. None of my location 5 and 10 records has a withholding_permissions key at all. Not present and false — simply never written. The file shows exactly what the code says. I like these one-to-one matches; they are the cheapest proof that I understood a mechanism correctly. Looking at the consent ledger for those same records produces a second result: Chrome PDF Viewer loc= 5 granted_api=0 active_api=7 Chrome Web Mağazası Ödemeleri loc=10 granted_api=2 active_api=1 Chrome built-in text-to-speech exten loc= 5 granted_api=0 active_api=5 Gemini in Chrome loc= 5 granted_api=0 active_api=2 Google Hangouts loc= 5 granted_api=0 active_api=4 Google Network Speech loc= 5 granted_api=0 active_api=4 Google Network Speech loc= 5 granted_api=0 active_api=4 Web Mağazası loc= 5 granted_api=0 active_api=0 Seven of the eight rows have granted_api at zero while active_api goes up to seven. The PDF viewer keeps seven API permissions active and not one of them is written in the "the user approved this" ledger. The single exception explains the rule: Chrome Web Mağazası Ödemeleri, at location = 10. IsComponentLocation counts it as a component, but kExternalComponent means "like a component, except installed from an update URL" — and it does have a consent record: granted_api=2, active_api=1. Whatever goes through an install gets a ledger entry too. That is not a leak; it follows from the definition. The consent ledger exists to track user consent, and components have no user-consent step. But it does clarify something: trusting the browser and trusting what you installed into the browser are not the same sentence. For the first one, there is no ledger you can audit. The extension I disabled kept its grant The User-Agent Switcher row has one property the table does not show: it carries disable_reasons: [1]. In extensions/browser/disable_reason.h, DISABLE_USER_ACTION = 1 << 0, which is 1. I turned it off myself. But its granted_permissions record is still in place: *://*/* is still written there. That makes sense. The code's own comment told us what granted_permissions is for: noticing permission escalation and alerting the user. If the consent record were deleted, Chrome would ask for approval again on every disable-and-enable cycle. From here I derive the following — I did not measure it, I am reasoning from the mechanism: when you re-enable a disabled extension whose manifest still asks for the same permissions, there is no difference between the approved set and the requested set, so no escalation warning arises. The extension picks up where it left off. The practical consequence: "I turned it off for now" is not a security measure, it is a postponement. A disabled extension is not running, but its consent is in its pocket. Approved but not active: one difference out of nine I made a separate pass looking for differences between granted_permissions and active_permissions. Per the code's comments, the first is the set the user approved and the second is the set the extension desires to have active. In theory these can diverge: an extension asks for a permission, gets approval, then stops asking for it, and the consent record stays behind. import json, os P = os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Secure Preferences") st = json.load(open(P, encoding="utf-8"))["extensions"]["settings"] diff = 0 for eid, v in st.items(): m = v.get("manifest") if not m or v.get("location") not in (1, 2, 6): continue g = set((v.get("granted_permissions") or {}).get("api") or []) a = set((v.get("active_permissions") or {}).get("api") or []) if g != a: diff += 1 print(f"{m.get('name')}: approved_not_active={sorted(g - a)} " f"active_not_approved={sorted(a - g)}") print("differing records:", diff, "/ 9") Across nine extensions, one line came back: Claude: approved_not_active=['system.display'] active_not_approved=[] differing records: 1 / 9 One permission. system.display — in Chrome's own documentation, the API used "to query display metadata". (It does have setter methods, but the docs mark every one of them as available only to ChromeOS Kiosk apps.) It is not in the extension's current manifest, neither under permissions nor optional_permissions, yet it sits in the granted set — so it looks like it was requested in an earlier version, approved, and later dropped, with the consent record left behind. Two things are worth separating here. First: the reverse direction is empty, so no extension is "active but not approved". That matters, because the opposite would be a sign of inconsistency. Second: this difference is not a risk, it is a residue. system.display is not being used right now. But the residue itself shows something — the consent ledger accumulates. Even when the set an extension asks for shrinks, the approval you gave stays on file, and if the extension puts that permission back into its manifest, no escalation warning will appear. You returned the book to the library, but your card still says you may borrow it. Exactly one permission granted at runtime The runtime_granted_permissions field holds permissions granted after install rather than at install time — anything requested through the permissions API or handed over manually from the site-access setting. I counted how many of the 22 records have it filled in: {"api": ["clipboardWrite"], "explicit_host": [], "manifest_permissions": [], "scriptable_host": []} records with a runtime grant: 1 / 22 One. One in the entire profile. And it is my password manager's clipboard-write permission — something it asked for at some point so it could copy a password, and I granted. The explicit_host and scriptable_host fields are empty: through that path I have never given any extension a single host permission. I think this is the most revealing number in the whole exercise. Chrome has a second path designed as "withhold first, grant if the user wants it"; it is written cleanly in the code, it has its own field in the ledger, and in my profile it has fired once — not for a host permission, but to write to the clipboard. The mechanism works. As a user, I have almost never walked through that door. Three extensions want debugger The part of this measurement that gave me the most pause was not a host pattern. It was a single API permission: three of the nine ask for debugger. The permissions list on developer.chrome.com spells out the warning this permission shows the user: "Read and change all your data on all websites." The same warning is produced by pageCapture, proxy, tabCapture and webAuthenticationProxy. So even if you never looked at the host pattern list, this one permission opens the same door. All three are AI assistant extensions. Reading the page and acting on it for me is exactly what I want from them, so seeing debugger was not a shock. Still, the distance between "can read the page" and "can drive the browser over the debugging protocol" is longer than the single line on the permission screen suggests. I want to keep the difference between installing that knowingly and installing it without noticing — and that is what this exercise gave me. There is also declarativeNetRequestWithHostAccess, whose definition in the list is blunt: it requires host permissions for all actions. Permissions lean on each other. A list that looks innocent read line by line collapses into a single sentence once you read it together. What stayed on disk Finally I looked at the file side. The Extensions/ folder holds 10 extension directories plus a Temp. Nine of them are the extensions in the table above; the tenth is Chrome Web Mağazası Ödemeleri, the only component with a directory on disk — precisely the location = 10 record installed from an update URL. The remaining components ship inside Chrome's own bundle, so they do not appear here. But the version subdirectories inside them add up to 16. The cause is a single extension: my ad blocker keeps 7 of its versions on disk at once. cd ~/Library/Application\ Support/Google/Chrome/Default/Extensions for d in */; do printf "%s %s\n" "$(ls -1 "$d" | wc -l | tr -d ' ')" "$d"; done | sort -rn | head -3 echo "version directories in total: $(ls -1 */*/ -d | wc -l | tr -d ' ')" 7 bgnkhhnnamicmpeenaelnjfhikgbkllg/ 1 pnlccmojcmeohlpggmfnbbiapkmbliob/ 1 pnhechapfaindjhompbnflcldabbghjo/ version directories in total: 16 All seven are consecutive patch releases of the same extension. The disk footprint is not dramatic, but it shows something: an update is not obliged to delete the old one. The manifest in the ledger points at the new version while the disk carries seven. There is one more thing: the DEPRECATED Secure Shell App row in the table. Its manifest carries an app key and a manifest_version of 2 — so it is not an extension but an old Chrome app. Despite "DEPRECATED" being in its name it is still in the ledger, seven API permissions still show as approved, and its disable_reasons field was never written. The "is it disabled" question is answered in the same dictionary: active_bit: false. Chrome does not consider it disabled, it simply is not active — the record just sits there. The consent record for abandoned software outlives the software. A checklist Here is the half-hour version you can run on your own machine: 1. Read extensions.settings out of Secure Preferences and compare the record count with the number you see in the panel. A difference is normal — not knowing the reason for the difference is not. Go find it. 2. Separate the records whose location is 1, 2 or 6. Those are the extensions you installed. (On Windows, add 3 = kExternalRegistry — that is the most common sideload path there; also 4 for unpacked and 8 for command-line loads.) 3. Look at the 2 and 6 ones specifically: a desktop application registered those with your browser. They may all be legitimate; you should still recognise the list. 4. Search granted_permissions for <all_urls> or *://*/*. How many of them genuinely need every site? 5. Count the withholding_permissions values. If they are all false, you have never used that setting. 6. Search the permissions lists for debugger, pageCapture, proxy and tabCapture. Independently of host patterns, these are an "everything on every site" door. 7. Compare the granted_permissions and active_permissions sets. "Approved but not active" rows are residue from old permissions; if anything comes back "active but not approved", stop — that is not an expected state. 8. Count the records with a non-empty runtime_granted_permissions. If the number is close to zero, you have never used Chrome's second permission path. 9. If you want to restrict one, the place is the extension's Details page, next to "Allow this extension to read and change all your data on websites you visit". The option names hold a small trap: Chrome's Help page says On select, while Chrome's own UI strings (chrome/app/extensions_strings.grdp) define that option as On click — and the same file also carries a newer set (Ask on every visit, Allow on specific sites, Allow on all sites). Whichever your screen shows, that is the row you want. This is the second place where the docs and the code diverge; the first was the default never being written down. My own decisions: the ad blocker and the password manager stay on all sites, because that is their job. React Developer Tools would work fine with "On specific sites" — right now the ledger says "all sites" for it too, and that is the first row I will change after this exercise. I am keeping the debugger permission on the AI extensions — but now I am keeping it knowingly, and before this exercise I did not know I was keeping it at all. Conclusion This measurement did not uncover a hole. What it uncovered is duller and, I think, more instructive: the system behaves exactly as designed, and the design treats the broadest permission as the default. The restriction mechanism exists, it works, it is written cleanly in the code — nobody just turns it on for me. In security the real question is usually not "which setting is wrong". It is "which default decided on my behalf, and which decision did I never see". That answer is not in the panel; panels show you the state after the decision. The decision itself sits in a one-line boolean written to disk at install time. Official Sources - Chromium — extensions/browser/extension_prefs.cc (permission pref keys and kDefaultWithholdingBehavior) - Chromium — extensions/browser/extension_util.cc (CanWithholdPermissionsFromExtension) - Chromium — extensions/common/mojom/manifest.mojom (ManifestLocation enum) - Chromium — extensions/browser/disable_reason.h (DISABLE_USER_ACTION) - Chromium — chrome/app/extensions_strings.grdp (site access UI strings) - MDN — manifest.json host_permissions - Chrome Web Store Help — Install and manage extensions (site access setting)

    3 reactions
  3. mainにマージされたっぽい github.com/chromium/chromium/commit/f0d6600866314… chromium-review.googlesource.com

    7 views
  4. dart:ioと端末のネットワーク設定 はじめに 2026年10月、eMudhraが発行した中間CA証明書が、鍵生成時の乱数の弱さによって侵害されていたことが公表されました。Dart SDKにも、この件のセキュリティアドバイザリが出ています。 github.com/dart-lang/sdk/security/advisories/GHSA… アドバイザリによると、AndroidとWindowsの dart:io は、SDKに組み込まれたBoringSSLで証明書チェーンを検証しており、OSが管理する中間証明書のブロックリストを読みません。そのため対処には、修正済みのSDK(Dart 3.13.7 / Flutter 3.47.8)でアプリを再ビルドし、配布し直す必要があります。一方で、iOSとmacOSは、AppleのSecurity.frameworkで検証しているため影響を受けません。Android端末には、Google Play Services経由で同等の更新が配信されていますが、dart:io で通信するアプリはその更新を使えません。 設計がそうなっているから、といえばそうなのですが、直感に反します。AndroidでもiOSのように、OSで管理される証明書を利用できないのでしょうか。 一部で知られているように、端末のproxy設定が反映されない問題があります。気づくタイミングが切羽詰まっている状況になることも多く、知っている方は若干辛い記憶なのではないでしょうか? flutter#26359 これも不思議です。Android端末上から通信をしているのに、その端末に設定されたproxyが反映されません。AndroidのNative SDKを使って開発していた頃には、全く意識する必要がなかったのに、です。 そして、結構厄介です。開発者の手元だと問題が起きないが、QAメンバーからの問い合わせで発覚したり。ユーザーから、特定環境で利用できない報告で発覚したりします。どうして。 なにが原因なのか dart:ioにおける、以下の特徴が原因です。 socketからTLSまで、ネットワーク処理をDart SDKの中に自前で実装している dart:io の実装からはJNIを呼べないため、AndroidのJava API(proxyの設定や TrustManager)に手が届かない proxyは、http_proxy などの環境変数しか見ず、OSの設定画面で指定したproxyを読まない Androidでは、ルート証明書を端末のディレクトリから直接読み、証明書の検証もSDK内蔵のBoringSSLで行う つまりAndroidでは、自前の実装がOSの仕組みを素通りするので、OSのネットワーク設定が効きません。それなら、まあ、しょうがないか…。 proxy、ルート証明書、証明書の検証 Security advisoriesにもある通り、今回の問題はAndroidとWindowsで発生し、iOS・macOSでは発生していません。これはiOS・macOSの dart:io が、証明書の検証をOSのSecurity.frameworkに任せていることに起因しています。 以下、よくわかっていなかったのでOpusにコードを読ませながら確認してみました。 proxy: 環境変数しか見ない (AndroidとiOS) HttpClient には、接続先のURLを受け取って、使うproxyを文字列で返す findProxy という関数を設定できます。 api.flutter.dev 返すのは、"PROXY host:port"(proxyを使う)か "DIRECT"(直接つなぐ)です。 この関数のデフォルトの findProxyFromEnvironment が見るのは、http_proxy・https_proxy・no_proxy という環境変数だけです。ターミナルで export http_proxy=... と設定する、あの環境変数です。この処理は、AndroidとiOSで共通です。 github.com/dart-lang/sdk/blob/3.13.7/sdk/lib/_htt… 本来、AndroidのWi-Fi設定やiOSの設定アプリで指定したproxyは、環境変数としては渡されません。このため、OSのAPIを経由して取得する必要があります。結果、dart:io においては、proxyの検知が働かないようです。 http#1414 sdk#41376 自前で実装しようとしても、ちょっとした壁があります。findProxy は同期関数です。AIに調べてもらったのですが、AndroidはPAC(Proxy Auto-Config)を同期処理で実装できそうです。しかし、iOS・macOSでPACを評価するAPIは、非同期になっています。結果、両OSをサポートする実装が難しくなっています。1 ルート証明書: ディレクトリを直接読む (Android) Android向けのTLS処理は、Linuxと同じ security_context_linux.cc の中にあります。証明書は、端末のディレクトリから直接読みます。2 github.com/dart-lang/sdk/blob/3.13.7/runtime/bin/… OSの設定を通らないので、こんな問題が出ています。 dart-lang/sdk#48056: 設定で無効にしたCA証明書を信頼し続ける dart-lang/sdk#50435: ユーザーが入れたCA証明書を信頼しない Android 14からはルート証明書が、Google Play system updatesで更新されるConscryptのAPEXモジュールに移りました。Dart 3.13.7から /apex/com.android.conscrypt/cacerts を読むようになるそうなので、この問題は徐々に解消していくと思われます。3 証明書の検証: OSのTrustManagerを通らない (Android) 冒頭のeMudhraに関する諸問題の、根本がここです。検証をSDKに内蔵するBoringSSLが行なっているため、Androidの TrustManager を利用しません。 結果、OSが配る中間証明書のブロックリストが効かない、という事態になったようです。Flutter 3.47.8(Dart 3.13.7)での修正は、次の2つです。 dart/568000: Dartに内蔵するBoringSSLを更新し、侵害されたeMudhraの中間CA証明書をブロックする dart/64696: Android 14以降で、更新可能なConscrypt APEXのルート証明書ストアを使う 中間証明書のブロックリストは、アプリに組み込まれたBoringSSLの中にあります。ルート証明書はOSの更新に追従するようになりましたが、中間証明書は追従しません。もちろん中間証明書のブロックリストが頻繁に更新されるなんてことはない、はずです。ただ、この違いは把握しておく必要があると思われます。 一方で、cronet_httpを使っていた場合は、証明書の検証をAndroidの TrustManager が行います(後述の補足を参照)。Android端末にはGoogle Play Services経由で同等の更新が配信されているので、Cronetで通信していたアプリは、SDKの更新やアプリの再ビルドを待たずに守られていたことになります。ただし、Google Play Servicesがない端末など、OSの更新を受け取れない端末は対象外です。 proxyの深掘り 証明書については、改善された点がある一方で、一層の覚悟も求められることになりました。では、proxyはどうでしょうか? sdk#50434 proxyの問題は、P3のIssueで追跡されています。issueのコメントを確認すると、AndroidではJNIを呼ぶしかない、という点が問題のようです。 We could also solve this issue in HttpClient. This would actually be fairly easy on iOS but harder on Android (AFAIK, the only way would be to make JNI calls to get the proxy configuration). sdk#50434 There is currently not a good way to make JNI calls from the dart:io implementation. flutter#20376 Android OSがJavaやKotlin向けに提供しているAPIなので、FFIではなくJNIが必要になり難しい、ということと筆者は理解しています。これはffigenとjnigenの安定度の違いを見たことがある方なら、まあそうだろうなって印象になると思います。 というわけで、何かしらの回避策が必要です。 かつての回避策: system_proxy かつて、Flutterのアプリを手探りで実装していた頃には、system_proxy などでOSのproxy設定を取ってくるのが典型的な対応でした。main() でなんか書いたことがあるな? って方は、多分これです。 github.com/kaivean/system_proxy/blob/0ad8536e39e3… ただ、このパッケージがやるのはproxyの取得だけで、使い方はアプリ任せです。 proxyを通すと、今度はTLSが問題になります。CharlesやTLSインスペクションのように、proxyがHTTPSを中継する環境では、proxyの証明書を信頼しないと通信できません。でも dart:io は、ユーザーが入れた証明書を信頼しません。そこで、こういうコードが書かれがちです。 HttpClient() ..findProxy = (uri) => 'PROXY $host:$port' ..badCertificateCallback = (cert, host, port) => true; badCertificateCallback が true を返すと、信頼できない証明書でも接続します。つまり、どんな証明書でも通るので、中間者攻撃に無防備になります。もちろん、適切に実装をすれば問題はありません。4 ただ、本来OSが適切に管理するべき通信が、アプリ実装者の手に委ねられてしまうのは危険です。2026年も終わろうとしている現在、そして、あれこれセキュリティに注目が集まる現在では推奨されないと感じています。 cronet_httpとcupertino_http OSが管理するproxyや証明書を使うのであれば、多くのAndroidやiOSのネイティブアプリと同じ通信経路を辿ればOKです。と書くとMethodChannelでリクエストを送ることをイメージするかもしれません。大丈夫です。Dartチームが用意しています。 パッケージ プラットフォーム 中身 cronet_http Android Cronet(Chromiumのネットワークスタック) cupertino_http iOS / macOS Foundation URL Loading System(URLSession) どちらのREADMEも、dart:io に対する利点の1つ目にproxy対応を挙げています。 cronet_http: "It automatically supports Android platform features such as HTTP proxies." cupertino_http: "It automatically supports iOS/macOS platform features such as VPNs and HTTP proxies." Charlesに通信が出ないという flutter/flutter#20376 も、iOSでは cupertino_http が使えるよ、というコメントでcloseされています。また、特にAndroidであれば、 cronet_http を使うことで証明書の問題も回避することができます。OkHttp版の開発も進んでいるので、この機会にぜひ検討してみてください。 補足 Opusに調べてもらった、それぞれのツールでproxyと証明書がどう扱われるかになります。本題からズレるので、気になる方はどうぞ。 Cronet(cronet_http)は、Androidのproxyと証明書をどう扱っているか proxy Cronetは初期化のときに、CreateProxyConfigService でproxyの設定の取得元を作ります。ここで呼ぶ CreateSystemProxyConfigService は、Androidでは ProxyConfigServiceAndroid を返します。 github.com/chromium/chromium/blob/55adf20e5753fc9… ProxyConfigServiceAndroid は、JNIでJavaの System.getProperty を呼び、次のシステムプロパティからproxyの設定を組み立てます(proxy_config_service_android.cc#L71-L173)。コード中のコメントによると、Javaの標準の ProxySelector とほぼ同じ読み方です。 http.proxyHost / http.proxyPort、https.proxyHost / https.proxyPort(なければ proxyHost / proxyPort) socksProxyHost / socksProxyPort http.nonProxyHosts など(proxyを使わないホストの一覧) 設定の変更は、Java側の ProxyChangeListener で受け取ります。PACが設定されている場合、CronetはPACのURLを使わず(set_exclude_pac_url(true))、Androidが用意するローカルのHTTP proxyに任せます(crbug.com/432539)。そのため、PACを評価する仕組み(proxy resolver)自体を持ちません(CreateWithoutProxyResolver)。 証明書の検証 Android向けのChromiumは、Chrome Root Storeを使う独自の検証器と、OSの検証器を選べます。ただし、Cronetのビルドでは chrome_root_store_optional = is_android && !is_cronet_build となり、この選択肢がありません。 github.com/chromium/chromium/blob/5691202e214341a… そのため CreateSystemVerifyProc が使われ(cert_verifier.cc#L30-L64)、Androidでは CertVerifyProcAndroid になります(cert_verify_proc.cc#L378-L379)。CertVerifyProcAndroid はJNIでJavaの AndroidNetworkLibrary.verifyServerCertificates を呼び(cert_verify_proc_android.cc#L100、AndroidNetworkLibrary.java#L120-L127)、最後は X509Util が、OSの TrustManagerFactory のデフォルトのトラストマネージャーで checkServerTrusted を呼びます。 github.com/chromium/chromium/blob/195962358a83d3d… ルート証明書の選び方も、チェーンの検証も、Androidの仕組みに任されているわけです。 なお、ここで読んでいるのはChromiumのソース(Cronetを同梱する場合のもの)です。Google Play Servicesが提供するCronetも同じ作りだと考えられますが、そのソースは公開されていません。 URLSession(cupertino_http)は、iOSのproxyと証明書をどう扱っているか proxy URLSessionConfiguration の connectionProxyDictionary は、デフォルトが NULL で、その場合はシステムの設定が使われます。 The default value is NULL, which means that tasks use the default system settings. developer.apple.com cupertino_httpは、connectionProxyDictionary を設定する公開APIを持たず、内部でも設定していません。そのため、defaultSessionConfiguration() などから作った CupertinoClient は、システムのproxy設定に従います。 証明書の検証 cupertino_httpは、認証チャレンジを受け取るdelegate(URLSession:task:didReceiveChallenge:completionHandler:)を実装していません。この場合、URLSessionはデフォルトの処理を行います(NSURLSession.h のコメント)。サーバー証明書の検証に、アプリ側の判定は入りません。 httpパッケージとdioでの差し替え 筆者がネイティブのHTTPクライアント推しなので、簡単ではありますが、置き換え方法を紹介します。 httpパッケージ package:http の Client() は、Web以外では IOClient、つまり dart:io の HttpClient を使います。何もしなければ、同じ問題を抱えたままです。 github.com/dart-lang/http/blob/http-v1.5.0/pkgs/h… READMEの "Choosing an implementation" のとおり、プラットフォームごとに Client を差し替えます。 import 'package:cronet_http/cronet_http.dart'; import 'package:cupertino_http/cupertino_http.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:http/io_client.dart'; Client createHttpClient() { switch (defaultTargetPlatform) { case TargetPlatform.android: final engine = CronetEngine.build(); return CronetClient.fromCronetEngine(engine, closeEngine: true); case TargetPlatform.iOS || TargetPlatform.macOS: final config = URLSessionConfiguration.defaultSessionConfiguration(); return CupertinoClient.fromSessionConfiguration(config); case TargetPlatform.fuchsia || TargetPlatform.linux || TargetPlatform.windows: return IOClient(); } } あとは Client をRiverpodのProviderで配布する、などすればOKです。もしも http.get() や Client() を直接呼んでいるところがあり、置き換えが難しいならば、runWithClient で包みます。ただし、Flutterではコールバックが同じZoneで実行される保証がないため、包んだ範囲でも IOClient に戻ることがあります。 pub.dev dio dioのデフォルトも、dart:io を使う IOHttpClientAdapter です。native_dio_adapter を使うと、cronet_http と cupertino_http に切り替えることができます。 final dio = Dio()..httpClientAdapter = NativeAdapter(); Androidでは CronetAdapter、iOS・macOSでは CupertinoAdapter、それ以外では従来どおり dart:io を使います。5 注意点 見逃しがちな点がいくつかあります。 観点 何が変わるか 見直すこと 画像の読み込み Image.network は dart:io のまま Client を渡せるパッケージに置き換える User-Agent クライアントごとにデフォルトの値が違う すべてのクライアントで明示的に指定する 例外 SocketException で捕まえられなくなる。dioではエラーの種類が変わる ClientException で捕まえる。DioException.type で分けている処理を見直す 画像の読み込み Flutter frameworkは任意のパッケージに依存しないので、Image.network は dart:io の HttpClient を直接使います。差し替える口はありません。6 画像も、Client を渡せるパッケージに置き換える必要があります。以下のようなパッケージが候補です。 パッケージ Client の差し替え ディスクキャッシュ http_image_provider できる なし extended_image できない(内部で dart:io の HttpClient を持つ) あり cached_network_image できる(flutter_cache_managerの HttpFileService) あり taro できる(TaroHttpClient) あり http_image_providerは、httpパッケージのREADMEが紹介している Flutter HTTP example application で使われています。一応、公式が提案しているパッケージです。 また cached_network_image は、ウィジェットごとに cacheManager: を渡すと渡し忘れが出るので、defaultCacheManager ごと差し替えるのが確実です。7 CachedNetworkImageProvider.defaultCacheManager = CacheManager( Config( 'networkImages', fileService: HttpFileService(httpClient: createHttpClient()), ), ); ちなみに、筆者は「画像だけ dart:io に戻る」状態をなんとかするだけなら、もっと軽量で行けるだろうと taro を作ってメンテナンスしています。デフォルトは dart:io ですが、TaroHttpClient を実装すれば Client に差し替えられます。サンプルも書いてあるので、よければご利用ください。 User-Agent 何も指定しないと、User-Agentはこうなります。 クライアント デフォルトのUser-Agent dart:io Dart/3.x (dart:io) Cronet <パッケージ名>/<versionCode> (Linux; U; Android 15; ja_JP; <端末名>; Build/<ID>; Cronet/<バージョン>) URLSession <アプリ名>/<ビルド番号> CFNetwork/<バージョン> Darwin/<バージョン> 「APIはCronet、画像は dart:io」のように混ざると、同じアプリから2種類のUser-Agentが飛びます。揃えたいなら、クライアントごとに指定します。 // Android CronetEngine.build(userAgent: userAgent); // iOS / macOS URLSessionConfiguration.defaultSessionConfiguration() ..httpAdditionalHeaders = {'User-Agent': userAgent}; // それ以外(dart:io) HttpClient()..userAgent = userAgent; native_dio_adapterなら、createCronetEngine と createCupertinoConfiguration に同じ設定を渡します。 例外 失敗したときの例外の型も変わります。 IOClient CronetClient CupertinoClient ClientException(SocketException も実装) CronetException とそのサブクラス NSErrorClientException IOClient の例外は、互換性のために SocketException も実装しています。なので on SocketException catch で捕まえているコードは、差し替えると素通りします。3つに共通するのは ClientException です。 dioでは、タイムアウトは常に receiveTimeout、通信の失敗は unknown になり、connectionTimeout や connectionError は返ってきません。 その他 cronet_httpとcupertino_httpが対応しているのは、Android・iOS・macOSだけです。WindowsのWinHTTP対応の要望(dart-lang/http#1837)には、Dartチームが取り組む可能性は低い、という回答でした。 おわりに package:http、というより dart:io には、あまり語られないものの把握しておくべきクセがあります。proxyや証明書のことを考えると、ネイティブのHTTPクライアントを利用するのが安牌なのではないかなと思っています。 🤏知ってることをまとめようと思い、AIに下調べと裏どりをさせてみたところ、自分も知らない話がたくさん出てきました。アプリ作るの、難しくない…? 脚注 dart-lang/sdk#44971 ↩︎ コード中のコメントによると、証明書をバイナリに組み込まないのは、バイナリのサイズを約240KB減らすためです。 ↩︎ それまでは /system/etc/security/cacerts を読んでいたそうです ↩︎ 環境ごとに適用を切り替えたり、証明書を限定したりすればOKのはず。 ↩︎ dio#2456 を入れたので、一応timeoutも効きます。 ↩︎ debugNetworkImageHttpClientProvider という差し替え口はありますが、デバッグビルド専用です。 ↩︎ flutter_cache_managerへの直接の依存が必要です。ディスクに保存するときの縮小(maxWidthDiskCache)も使う場合は、ImageCacheManager をmixinしたクラスにします。 ↩︎

  5. One domain seller is 6% of the HSTS preload list --- title: "One domain seller is 6% of the HSTS preload list" published: true description: "5,691 entries on Chromium's HSTS preload list are one seller's domains for sale. Chromium cut them twice, they came back twice, and Firefox kept losing them." tags: "security, web, hsts, browsers" canonical_url: "blog.peng.ly" cover_image: "blog.peng.ly" ai_assisted: true --- The HSTS preload list is the list of sites your browser will only ever load over HTTPS, before it's even visited them once. It's built into Chrome and Edge, and Firefox makes its own copy from it. You get on by serving HTTPS on every subdomain, sending one header and filling in a form, and the idea is that it's for sites that care about HTTPS enough to promise it for good. I pulled Chromium's copy on 17 October and looked up all 95,147 entries, mostly out of nosiness about who's on it. The two addresses with the most names on them both belong to someone selling domains. !Who's on the HSTS preload list Who's on the HSTS preload list (the data) _Chromium's entries by the first IPv4 address they resolve to, 17 October 2026. Below the top two, every row is a host with lots of customers or one company's own sites. Cloudflare's behind far more than any of these, but spread over lots of addresses._ That's 5,691 entries between them, about one in every seventeen on the list. Pick one at random and you'll get a white page with the name in capitals, like "HAOMIN.COM MAY BE AVAILABLE!", a button to buy it on GoDaddy and "Proudly Powered by GKJ.com" at the bottom. GKJ's own site says "You came looking for a domain. We probably already own it", and puts the count at 5,000+. Who's registered them is redacted, so I'm going on their word that it's one seller. I fetched them slowly in random order until the server started telling me to go away. 498 of the 511 that answered were that sale page, 7 more said "UNDER CONSTRUCTION" in the same style, and all 511 sent max-age=63072000; includeSubDomains; preload. There's nothing against the rules in that. When I ran 20 of them through Chromium's own checker this afternoon, all 20 said "Satisfies requirements". Why a domain seller bothers, I don't know. The few pages the Wayback Machine kept from 2022 to 2024 come from the same kit, with the name in capitals over a slogan like "IN GOD WE TRUST", and the 2022 ones already sent the header. So my guess is it just comes with whatever makes the pages. It does matter to whoever buys one, though. They get a domain that browsers will only load over HTTPS, and every subdomain under it too, so nas.theirname.com with a self-signed certificate on the home network won't load, because HSTS takes away the button that lets you click through the warning. Getting off the list means asking for removal and then waiting, which hstspreload.org reckons "may take 6-12 weeks to reach most Chrome users, and may take longer for other browsers". You can check any name first with curl "https://hstspreload.org/api/v2/status?domain=haomin.com", which says "status": "preloaded" for this one. Chromium cut them twice !This seller's names on Chromium's list This seller's names on Chromium's list (the data) _How many of today's 5,691 names were on the list at the end of each month, January 2022 to October 2026._ Nine in ten of them went on in one bulk update in June 2022. In April 2023 Chromium pruned every domain that had "repeatedly failed to meet the continued requirements", which took out 41% of the list and 91% of this seller's names. They went back through the same form as everyone else, and most were back on within six months. Then a bulk update in August 2025 did it again, cutting the list from 171,166 entries to 95,329 and the seller from over 5,500 to 405. Of 40 other names it removed, every one still fails today, mostly because nothing answers, so it wasn't a bad clear-out. It just took these with it, and 4,659 of the names on the list today have now been cut twice. They were back within months again, and because that update cleared out so much else, this seller's now pretty much all of the list's growth. Since August 2025 the whole list has shrunk by 182 entries, while this one block grew by 5,286. Firefox kept losing them Firefox doesn't take Chromium's list as it is. A script visits every site on it about once a week and drops the ones that don't send the header. Visiting thousands of sites on one server all at once is exactly what this server doesn't like. When I fetched all 5,691 at 64 at a time, nearly every answer was a 429 Too Many Requests, and not one of those had the header. So in Firefox they came and went depending on how the week's run went: !This seller's names on Firefox's list This seller's names on Firefox's list (the data) _Each update to Firefox's preload list since June 2026. Mozilla's fix went in on 5 October, between the 2 and 6 October updates._ Ryan VanderMeulen at Mozilla spotted it last month. In bug 2075853 he wrote that Chromium's list "holds thousands of domains of one hosting operator in a contiguous block, all served from two addresses", and that checking them in list order meant "the operator answered with 429 for thousands of hosts that otherwise send a valid header, which kept them off the preload list". The fix landed on 5 October. The script now shuffles the list, tries 429s again at the end, and an error page without the header doesn't knock a site off anymore. Before that, a run where more of them got through, like 26 September's, was gone again the week after. Since then every update has kept what it had and added more, up to 2,275 on 16 October. That's still well short of 5,691, and the bug says why: "the operator still limits the rate over the run as a whole". Chromium's checker gets waved through I'd assumed Chromium's culls tripped over the same 429s. Its removal job checks every bulk entry weekly, only looks at the header and not the status code, and queues a domain for removal once it's failed for a month. But Chromium's checker calls itself hstspreload-bot, so this evening I got the server annoyed with 300 quick requests, then asked ten more names for their page under four different User-Agents: | User-Agent | What came back | HSTS header | | --- | --- | --- | | Chrome | 429 | no | | Firefox | 429 | no | | Googlebot | 429 | no | | hstspreload-bot | 200 | yes | Same for all ten names. So the rate limit lets Chromium's checker straight through, and it's everyone else who gets the 429, Firefox included. What it did in 2023 and 2025 I can't tell, and the Wayback Machine won't show me enough from just before either cull to find out. They pass Chromium's checks now, and they were cut anyway.

  6. Your money input probably thinks 5,04 is 504 --- title: "Your money input probably thinks 5,04 is 504" published: true description: "Type a European 5,04 into Chrome's number box or most popular JavaScript money libraries on an English page and you get 504, with no error." tags: "javascript, i18n, forms, chrome" canonical_url: "blog.peng.ly" cover_image: "blog.peng.ly" ai_assisted: true --- Type 5,04 in here, the way most of Europe writes five euros and four cents: Try it on the blog In Chrome with its menus in English, the comma never goes in. You're left with 504, and nothing anywhere says anything went wrong. Firefox tells you it's not a number, which is at least honest. Chrome bins the comma as you type Blink checks each keystroke against 0123456789.Ee-+ plus the locale's own number characters, and the code that builds that list leaves one out on purpose: // We don't accept group separators. if (i != kGroupSeparatorIndex) builder.Append(decimal_symbols_[i]); In English the comma is the group separator, so it's gone before your page ever sees it. input.value is "504" and validity.badInput is false, so there's nothing to catch. The locale is Chrome's own menu language, not your page's. An <input lang="de-DE"> in an English Chromium still gave me 504, and starting Chromium in German gave 5.04. Firefox goes by the page's lang instead, so it's bad input on an English page and 5.04 on a German one. The libraries do it too Last week someone in Costa Rica posted on Hacker News about being charged $504 for $5.04 of API credit. Another commenter, in Germany with their Linux set to English, said they've ended up selling things on Amazon and eBay "for 599,00 instead of 5.99". How many of the libraries people put on money fields would do the same? I took the 20 most downloaded npm packages that turn a typed amount into a number (parsers, masks and input components), set no locale or separator options, and ran them in an English browser on an English page. Inputs and masks got typed into a key at a time (all but ngx-mask in Chromium and Firefox), and the plain parsers I just called. | Package | 5,04 | 1.234,56 | | ----------------------------------- | ---- | -------- | | @internationalized/number | 504 | 1.23456 | | react-number-format | 504 | 1.23456 | | rc-input-number | 504 | 1.23456 | | @rc-component/input-number | 504 | 1.23456 | | numeral | 504 | 1.23456 | | currency.js | 504 | 1.23 | | react-currency-input-field | 504 | 1.23 | | numbro | 504 | 1.23456 | | accounting | 504 | 1.23456 | | cleave.js | 504 | 1.23 | | text-mask-addons (with decimals on) | 504 | 1.23 | | currency-formatter | 504 | 1.23456 | | autonumeric | 504 | 1.23 | | imask | 5.04 | 1.23 | | react-imask | 5.04 | 1.23 | | ngx-mask | 5.04 | 1.23 | | vue-currency-input | 5.04 | 1.23 | | inputmask | 5.04 | 1.56 | | globalize | NaN | NaN | | parse-decimal-number | NaN | NaN | 13 of the 20 make 504 of it, and they're not obscure ones. @internationalized/number is Adobe's, and it's what React Aria's NumberField parses with. rc-input-number and its renamed successor are what Ant Design's InputNumber is built on. The German amount is worse. 18 of the 20 turn a thousand-odd euros into one and a bit, and I've no idea where inputmask's 1.56 comes from. Only globalize and parse-decimal-number say no to both. Why 504 In US English a comma groups thousands, so the quick way to read "$1,234" is to delete the commas, and that's what most of these do. rc-input-number's whole default parser is one line, with a comment on what it's for: // [Legacy] We still support auto convert `$ 123,456` to `123456` return parsedStr.replace(/[^\w.-]+/g, ''); But nobody in the US writes 5,04. Groups come in threes, so a comma with two digits after it can't be grouping anything. It's someone typing a decimal the other way round. globalize and parse-decimal-number check for that, and nothing else does: > currency('5,04').value 504 > numeral('5,04').value() 504 > new NumberParser('en-US').parse('5,04') 504 > parseDecimalNumber('5,04') NaN A German browser just flips it Two of the 13 only made 504 because my browser was in English. react-currency-input-field and React Aria's NumberField (the field on top of @internationalized/number) take their separators from the browser's language when you don't give them any. In a German browser they read 5,04 fine, and turn 5.04 into 504 instead. The Costa Rica poster says that's their case, with settings that use a comma and 5.04 typed in (the company's reply in the thread says 5,04, so I'm not sure which). Every library's locale option works, for what it's worth. Give react-number-format or rc-input-number decimalSeparator=",", or react-currency-input-field a German intlConfig, and a typed 5,04 comes out as 5.04. That just moves the problem to whoever types a dot. Catching it First you need what they actually typed, so it's a text input rather than type="number", since Chrome's binned the comma by the time you look. inputmode="decimal" gets phones a number pad. Then check it: // commas only ever group threes const looksWrong = (typed) => /,(?!\d{3}(\D|$))/.test(typed); That catches 5,04, 599,00 and 1.234,56 and lets 1,234.56 through. Swap the comma for a dot on a German page. When it fires, ask what they meant rather than charging them for it. It won't catch 5,040, which comes out as five thousand and forty here and in globalize and parse-decimal-number too. Nothing can tell that one from the string.

  7. 301 redirect change doesn't take effect for some users On the documentation site, /docs redirects to the pages for the latest version. Links on other sites, such as the product site, point to /docs. For the v4 release, the nginx config was edited to change the target from /docs/v3/ to /docs/v4/. location = /docs { return 301 /docs/v4/; } 301 is the status code meaning the requested URL has moved permanently to another URL. The /docs URL itself will not change, so it was set up as a 301. After the change, reports keep coming in: "When I open it from the link on the product site, I get v3." Running curl against /docs locally returns a 301 to v4. $ curl -sI https://<host>/docs HTTP/1.1 301 Moved Permanently Location: https://<host>/docs/v4/ I reproduced this with nginx and browsers and measured why some users land on v3. Impact Some of the users who opened /docs from the product site link while it pointed to v3 keep reading the v3 pages after the v4 release. No error appears, so only the users who notice send a report. The ones who don't notice go on configuring and working by the v3 instructions. This does not go away, however many days pass. It happens again every time a release changes the target. Reproduction I reproduced the scenario above with nginx 1.31.6 and three browsers driven by Playwright 1.63.0: Chromium, WebKit (the engine behind Safari), and Firefox.[^env] [^env]: Run on GitHub Actions (ubuntu-24.04). Browser versions: Chromium 153.0.8010.12, WebKit 26.6, Firefox 155.0. The logs and curl output in the text have the per-condition paths and host names rewritten to /docs and <host>. The config, scripts, and raw logs are in show-your-work. A page containing only a link to /docs is served on a host name separate from the documentation site, and /docs is opened from that page. /docs responds with the same return 301 as above. While the target is v3, open /docs from the link page. Then change the nginx config to v4 and reload. After that, open /docs from the link page three times: right after the switch, 70 seconds later, and after restarting the browser. Each time, record which version's page the browser landed on. Chromium and WebKit landed on v3 all three times. Firefox landed on v4 from right after the switch.[^run] [^run]: Verification results: CI job When /docs was opened while the target was v3, all three browsers left a /docs line in the nginx access log. <client IP> - - [15/Oct/2026:23:13:51 +0000] "GET /docs HTTP/1.1" 301 169 ... After the switch, there was no /docs line for any of the three times Chromium and WebKit landed on v3. Firefox, which landed on v4, left a /docs line all three times. Chromium and WebKit went to v3 without asking the server. Why v3 keeps showing The /docs 301 does not say how long it may be stored and reused. How to treat a 301 without that lifetime is up to the browser. Chromium treats it as never expiring and WebKit as valid for one year, and both reuse the stored 301 without asking the server.[^chromium][^webkit] So users who stored the 301 to v3 still land on v3 after the switch. Firefox does not reuse the stored 301 as is. It asks the server every time the page is opened. These are the headers of the /docs 301: $ curl -sI https://<host>/docs HTTP/1.1 301 Moved Permanently Server: nginx/1.31.6 Date: Thu, 15 Oct 2026 23:14:03 GMT Content-Type: text/html Content-Length: 169 Location: https://<host>/docs/v4/ Connection: keep-alive Cache-Control: max-age=<seconds> is how a response tells the browser it may store the response and use it without asking for that many seconds. This response has no such header. Nor does it have Last-Modified, the header that gives the time the file was last modified. Firefox computes the lifetime of a 301 without a stated lifetime from Last-Modified.[^firefox] This response has no Last-Modified, so the lifetime is 0, which means the response is already expired when it is stored. That is why Firefox asks the server every time. [^chromium]: Chromium's http_response_headers.cc (GetFreshnessLifetimes). A 301 without a stated lifetime never expires. A 302 gets 0. [^webkit]: WebKit's CacheValidation.cpp (computeFreshnessLifetimeForHTTPFamily). A 301 without a stated lifetime gets one year. Anything else gets a lifetime computed from Last-Modified, or 0 if there is none. [^firefox]: Firefox's nsHttpResponseHead.cpp (ComputeFreshnessLifetime). A 301 without a stated lifetime gets a lifetime computed from Last-Modified, or 0 if there is none. A 302 without a stated lifetime gets 0. Fix Switch to a 302, or give the 301 a lifetime with max-age. Either way, users who have already stored the 301 to v3 stay on v3. Those users are covered in the notes at the end. Use a 302 302 is the status code meaning the requested URL has moved temporarily to another URL. In nginx: location = /docs { return 302 /docs/v4/; } For a 302 with neither a stated lifetime nor Last-Modified, Chromium, WebKit, and Firefox all set the lifetime to 0 and ask the server for /docs every time it is opened.[^chromium][^webkit][^firefox] Add max-age to the 301 You can also keep the 301 and state a lifetime with max-age in Cache-Control. The verification used 60 seconds. location = /docs { add_header Cache-Control "max-age=60"; return 301 /docs/v4/; } Headers added with add_header are also attached to the 301 sent by return. The tradeoff is that users who opened /docs just before a release land on the previous version for up to max-age seconds after the release. Which to choose I choose the 302. At the start, /docs got a 301 because its URL does not change. A 301, however, says that the move from /docs to its target is permanent. The target changes with every release, so the move is temporary. I don't choose max-age. It saves one round trip to /docs during the lifetime, but in exchange, users who opened /docs just before a release land on the previous version. Effect of the fixes With a 302 With the config already using return 302 while the target was v3, I opened /docs with the same steps as the reproduction.[^run] | Time | Chromium | WebKit | Firefox | |---|---|---|---| | Right after the switch | v4 | v4 | v4 | | 70 seconds later | v4 | v4 | v4 | All three browsers landed on v4 from right after the switch. With max-age on the 301 With the config already returning the 301 with max-age=60 while the target was v3, I opened /docs with the same steps as the reproduction.[^run] | Time | Chromium | WebKit | Firefox | |---|---|---|---| | Right after the switch | v3 | v3 | v3 | | 70 seconds later | v4 | v4 | v4 | Right after the switch, Firefox also used the stored 301 and landed on v3. At 70 seconds, after the 60-second lifetime had passed, all three landed on v4. Notes Affected browsers Chromium-based browsers and Safari keep landing on v3. Firefox 139 and later are not affected. Firefox 138 and earlier, and the ESR releases based on them, are. Firefox 138 and earlier treated a 301 without a stated lifetime as never expiring.[^ff-bug] [^ff-bug]: Mozilla Bug 1957619. Chromium stores the 301 separately depending on whether the page was opened from a link on another site or typed into the address bar.[^chromium-key] In the verification, after the 301 to v3 was stored by opening /docs from the link page and the target was switched to v4, opening /docs directly, without going through a link on another site, took Chromium to v4 and WebKit to v3.[^run] [^chromium-key]: Chromium's http_cache.cc. Top-level navigations coming from another site are stored under a separate cache key. To check this in your own Chrome, open /docs from a link on another site, the way users do. Users who still land on v3 after changing to 302 I also ran the case where the 301 to v3 is stored first and the config is changed to return 302 at the switch, opening /docs with the same steps as the reproduction. Chromium and WebKit landed on v3 all three times: right after the switch, 70 seconds later, and after restarting the browser.[^run] They keep using the stored 301 to v3, so the change to 302 never reaches these users. Clear-Site-Data is a header that tells the browser receiving it to delete that host's cache and other stored data. In the verification, the v4 page, on the same origin as /docs, returned this header. location /docs/v4/ { add_header Clear-Site-Data '"cache"'; } For users who opened this page, the stored 301 was deleted, and Chromium and WebKit also landed on v4 from /docs.[^run] The header only reaches users who open a v4 page. Users being sent to v3 never get to a v4 page from /docs. Note that the header tells the browser to delete the entire cache for that host, not just the v4 page. The header also works only over HTTPS (or on localhost).[^csd] [^csd]: W3C's Clear Site Data. "cache" deletes the cache for the same host as the response URL (4.2.3). If the response URL is not treated as secure (HTTPS and localhost URLs are), nothing is deleted (4.2). Setups that give the redirect a lifetime If expires is set at the server level, the 301 sent by return in a location also gets a Cache-Control header.[^nginx-expires] In the verification, with expires 30d at the server level and only return 301 in the /docs location, the 301 for /docs got Cache-Control: max-age=2592000 (30 days).[^run] Users land on the previous version for up to 30 days after the release. [^nginx-expires]: nginx's ngx_http_headers_module. expires adds Expires and Cache-Control to responses with status code 301 as well. Setups not tested - Sites with a registered Service Worker that answers requests to /docs with a locally held response - Setups with a CDN in front of nginx that stores the /docs 301 and serves it In both, the request does not reach nginx, so the /docs lines in the access log cannot tell you whether the browser used a stored 301.

  8. 301 リダイレクトの変更が一部の利用者に反映されない ドキュメントサイトの /docs は最新版のページへのリダイレクトにしている。製品サイトなど、ほかのサイトに貼るリンクは /docs に向けている。v4 のリリースで、nginx の設定を書き換え、行き先を /docs/v3/ から /docs/v4/ に変えた。 location = /docs { return 301 /docs/v4/; } 301 は、リクエストされた URL が恒久的に別の URL に移ったことを表すステータスコードだ。/docs という URL はこの先も変えないので、301 にした。 変えたあとも、「製品サイトのリンクから開くと v3 が出る」という問い合わせが来る。手元で /docs を curl で叩くと、v4 への 301 が返ってくる。 $ curl -sI https://<host>/docs HTTP/1.1 301 Moved Permanently Location: https://<host>/docs/v4/ この状態を nginx とブラウザで再現し、v3 に着く理由を実測で確かめた。 影響 v3 の時期に製品サイトのリンクから /docs を開いた利用者の一部は、v4 のリリース後も v3 のページを読み続ける。エラーは出ないので、問い合わせてくるのは気づいた利用者だけだ。気づかない利用者は v3 の手順で設定や作業を進める。 この状態は何日経っても直らない。リリースで行き先を変えるたびに、同じことが起きる。 再現 冒頭の状態を、nginx 1.31.6 と、Playwright 1.63.0 で動かす Chromium、WebKit(Safari と同じエンジン)、Firefox で再現した1。 ドキュメントサイトとは別のホスト名で、/docs へのリンクだけを置いたページを配信し、そこから /docs を開く。/docs は冒頭と同じ return 301 で返す。 行き先が v3 のあいだに、リンクのページから /docs を開く。nginx の設定を v4 に書き換えてリロードする。そのあと、向け直した直後、70 秒後、ブラウザを再起動したあとの 3 回、リンクのページから /docs を開き、どのバージョンのページに着いたかを記録した。 Chromium と WebKit は 3 回とも v3 に着いた。Firefox は向け直した直後から v4 に着いた2。 v3 の時期に開いたときは、3 つのブラウザとも nginx のアクセスログに /docs の行を残している。 <利用者の IP> - - [15/Oct/2026:23:13:51 +0000] "GET /docs HTTP/1.1" 301 169 ... 向け直したあと、Chromium と WebKit が v3 に着いた 3 回とも、/docs の行はなかった。v4 に着いた Firefox は 3 回とも /docs の行を残している。Chromium と WebKit は、サーバーに問い合わせずに v3 に飛んでいる。 v3 を表示し続ける原因 /docs の 301 には、保存して使ってよい期限が書かれていない。期限のない 301 をどう扱うかはブラウザが決める。Chromium は期限なし、WebKit は 1 年として、保存した 301 をサーバーに問い合わせずに使う34。そのため、v3 行きの 301 を保存した利用者は、向け直したあとも v3 に着く。Firefox は保存した 301 をそのまま使わず、開くたびにサーバーに問い合わせる。 /docs の 301 のヘッダは次のとおりだ。 $ curl -sI https://<host>/docs HTTP/1.1 301 Moved Permanently Server: nginx/1.31.6 Date: Thu, 15 Oct 2026 23:14:03 GMT Content-Type: text/html Content-Length: 169 Location: https://<host>/docs/v4/ Connection: keep-alive Cache-Control: max-age=<秒数> は、ブラウザが応答を保存し、その秒数のあいだ問い合わせずに使ってよいという期限の書き方で、この応答にはない。ファイルの更新時刻を表す Last-Modified もない。 Firefox は、期限の書かれていない 301 の期限を Last-Modified から計算する5。この応答には Last-Modified がないので期限は 0、つまり保存した時点で期限切れになる。そのため Firefox は開くたびにサーバーに問い合わせる。 対策 302 にするか、301 に max-age で期限を書く。すでに v3 行きの 301 を保存した利用者は、どちらにしても v3 に着いたままになる。この利用者は補足で扱う。 302 にする 302 は、リクエストされた URL が一時的に別の URL に移ったことを表すステータスコードだ。nginx では次のように書く。 location = /docs { return 302 /docs/v4/; } 期限も Last-Modified も書かれていない 302 は、Chromium、WebKit、Firefox とも期限を 0 にし、開くたびに /docs をサーバーに問い合わせる3:14:15:1。 301 に max-age を付ける 301 のまま、Cache-Control の max-age で期限を書くこともできる。検証では 60 秒にした。 location = /docs { add_header Cache-Control "max-age=60"; return 301 /docs/v4/; } add_header で足したヘッダは、return で返す 301 の応答にも付く。 代償として、リリースの直前に /docs を開いた利用者は、リリースから最長で max-age の秒数のあいだ前のバージョンに着く。 どちらを選ぶか 私は 302 を選ぶ。冒頭では /docs の URL を変えないので 301 にした。301 は、/docs から行き先への移動が恒久的であることを表す。リリースのたびに行き先が変わるので、この移動は一時的だ。 max-age は選ばない。期限のあいだ /docs への 1 往復は省けるが、その代わりに、リリースの直前に開いた利用者が前のバージョンに着く。 対策の効果 302 にした場合 v3 の時期から return 302 で返す設定にして、再現と同じ手順で /docs を開いた2:1。 時点 Chromium WebKit Firefox 向け直した直後 v4 v4 v4 70 秒後 v4 v4 v4 3 つのブラウザとも向け直した直後から v4 に着いた。 301 に max-age を付けた場合 v3 の時期から 301 に max-age=60 を付けて返す設定にして、再現と同じ手順で /docs を開いた2:2。 時点 Chromium WebKit Firefox 向け直した直後 v3 v3 v3 70 秒後 v4 v4 v4 向け直した直後は、Firefox も保存した 301 を使い、v3 に着いた。期限の 60 秒が過ぎた 70 秒後には、3 つとも v4 に着いた。 補足 起きるブラウザ v3 に着き続けるのは、Chromium 系のブラウザと Safari だ。Firefox は 139 以降なら起きない。138 以前と、それを元にした ESR では起きる。138 以前の Firefox は、期限の書かれていない 301 を期限なしとして扱っていた6。 Chromium は別サイトのリンクから開いたときと、アドレスバーに打ったときとで、301 を別々に保存する7。検証で、リンクのページから開いて v3 行きの 301 を保存し、v4 に向け直したあと別サイトのリンクを経ずに /docs を直接開くと、Chromium は v4 に、WebKit は v3 に着いた2:3。 手元の Chrome で確かめるときは、利用者と同じく、別サイトのリンクから開く。 302 に変えても v3 に着く利用者 301 で v3 行きを保存させたあと、向け直すときに return 302 に変えた設定でも、再現と同じ手順で /docs を開いた。Chromium と WebKit は、直後、70 秒後、再起動後の 3 回とも v3 に着いた2:4。保存した v3 行きの 301 を使い続けるので、302 に変えたことはこの利用者に伝わらない。 Clear-Site-Data は、応答を受け取ったブラウザに、そのホストのキャッシュなどを消させるヘッダだ。検証では、/docs と同じオリジンの v4 のページから、このヘッダを返した。 location /docs/v4/ { add_header Clear-Site-Data '"cache"'; } このページを開いた利用者は、保存した 301 が消え、Chromium と WebKit でも /docs から v4 に着いた2:5。ヘッダが届くのは、v4 のページを開いた利用者だけだ。v3 に飛ばされている利用者は、/docs からは v4 のページに着かない。 ただし、このヘッダが消すように指示するのは、v4 のページだけでなく、そのホストのキャッシュ全体だ。また、このヘッダは HTTPS(または localhost)でしか効かない8。 リダイレクトに期限が付く構成 server の階層で expires を設定していると、location の return で返す 301 にも Cache-Control が付く9。検証で server の階層に expires 30d を書くと、location に return 301 だけを書いた /docs の 301 に、Cache-Control: max-age=2592000(30 日)が付いた2:6。利用者はリリースから最長 30 日、前のバージョンに着く。 確かめていない構成 Service Worker を登録していて、Service Worker が /docs へのリクエストに手元の応答を返すサイト nginx の前に CDN を置き、CDN が /docs の 301 を溜めて返す構成 どちらも、リクエストが nginx まで届かないので、ブラウザが保存した 301 を使ったかを、アクセスログの /docs の行では見分けられない。 脚注 GitHub Actions(ubuntu-24.04)で動かした。ブラウザのバージョンは Chromium 153.0.8010.12、WebKit 26.6、Firefox 155.0。本文のログと curl の出力は、条件ごとのパスとホスト名を /docs と <host> に書き換えている。設定・スクリプト・生ログは show-your-work にある。 ↩︎ 検証結果:CI のジョブ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ Chromium の http_response_headers.cc(GetFreshnessLifetimes)。期限の指定がない 301 は期限なし、302 は 0 にする。 ↩︎ ↩︎ WebKit の CacheValidation.cpp(computeFreshnessLifetimeForHTTPFamily)。期限の指定がない 301 は 1 年、それ以外は Last-Modified から期限を計算し、なければ 0 にする。 ↩︎ ↩︎ Firefox の nsHttpResponseHead.cpp(ComputeFreshnessLifetime)。期限の指定がない 301 は、Last-Modified から期限を計算し、なければ 0 にする。期限の指定がない 302 は 0 にする。 ↩︎ ↩︎ Mozilla の Bug 1957619。 ↩︎ Chromium の http_cache.cc。別サイトから来たトップレベルの遷移を、別のキャッシュキーで保存する。 ↩︎ W3C の Clear Site Data。"cache" は応答の URL と同じホストのキャッシュを消す(4.2.3)。応答の URL が HTTPS や localhost のような安全とみなす URL でなければ、何も消さない(4.2)。 ↩︎ nginx の ngx_http_headers_module。expires は、ステータスコードが 301 の応答にも Expires と Cache-Control を足す。 ↩︎

    1 like
  9. I've opened up a PR adding an example certificate generated using these keys to badssl.com - badssl.com#559

  10. Chromeで\\wsl.localhostも\\wsl$も開けなくなってるじゃねーか。ふざけやがって…。 github.com/chromium/chromium/commit/bc5a321c6196d…

    1 like · 29 views

Achievements

Awards earned by the repositories chromium owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.