···991010## [Unreleased]
11111212+- Migrate/refactor everything from `content.js` into a self-contained `background.js`
1313+- Swap `activeTab` permission for `tabs` so that we can drop the `<all_urls>` permission
1414+- Remove permissions for "management" as it is not needed
1515+- Migrate Firefox to Manifest v3 (but this means the minimum FF version is now 109)
1616+- Input validation for domains and DIDs (security enhancement)
1717+- Replace `staging.bsky.app` with `bsky.app`
1818+1219## [1.3.0]
13201421### Added
+4-4
README.md
···15151616---
17171818-As of April 30, 2023, Bluesky is still invite-only and the web app is at https://staging.bsky.app.
1818+As of May 17, 2023, Bluesky is still invite-only and the web app is at https://bsky.app.
19192020-You can find me there at [@adhdjesse.com](https://staging.bsky.app/profile/adhdjesse.com)
2020+You can find me there at [@adhdjesse.com](https://bsky.app/profile/adhdjesse.com)
21212222**Contributors:**
23232424-[@danielhuckmann.com](https://staging.bsky.app/profile/danielhuckmann.com) - Firefox Support & Privacy Consent
2525-[@aliceisjustplaying](https://staging.bsky.app/profile/alice.bsky.sh) - HTTPS Method of DID Detection
2424+- [@danielhuckmann.com](https://bsky.app/profile/danielhuckmann.com) - Firefox Support, Privacy & Security Enhancements
2525+- [@aliceisjustplaying](https://bsky.app/profile/alice.bsky.sh) - HTTPS Method of DID Detection
+168-30
background.js
···44const tabs = typeof browser !== "undefined" ? browser.tabs : chrome.tabs
55const storage =
66 typeof browser !== "undefined" ? browser.storage.local : chrome.storage.local
77-const action =
88- typeof browser !== "undefined" ? browser.browserAction : chrome.action
77+const action = typeof browser !== "undefined" ? browser.action : chrome.action
88+99+// Make sure that we don't DoS the regex if someone supplies too large of a DID
1010+const MAX_DID_LENGTH = 255
1111+1212+// Regular expression to validate the DID format
1313+// https://w3c.github.io/did-core/#did-syntax
1414+const didRegex =
1515+ /^did:plc:([a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)*|((%[0-9A-Fa-f]{2})|[a-zA-Z0-9._-])+(:((%[0-9A-Fa-f]{2})|[a-zA-Z0-9._-])+)*$)/
1616+1717+// Function to validate the DID string
1818+function isValidDID(didString) {
1919+ return didString.length <= MAX_DID_LENGTH && didRegex.test(didString)
2020+}
2121+2222+// Function to get the domain name from the current hostname
2323+function getDomainName(url) {
2424+ const hostname = new URL(url).hostname
2525+ return hostname.replace(/^www\./, "")
2626+}
2727+2828+// Function to validate the domain name
2929+function isValidDomain(domain) {
3030+ const MAX_DOMAIN_LENGTH = 255
3131+3232+ if (domain.length > MAX_DOMAIN_LENGTH) {
3333+ return false
3434+ }
3535+3636+ try {
3737+ // Use the build in URL constructor to validate the URL, if doesn't throw an error, the domain is valid
3838+ // This is a better choice than a regex since it should properly support punycode/international domains
3939+ new URL(`https://${domain}`)
4040+ return true
4141+ } catch (error) {
4242+ // The URL constructor threw an error, so the domain is not valid
4343+ return false
4444+ }
4545+}
4646+4747+// Function to check for a DID in the domain's TXT records
4848+async function checkForDIDDNS(domain) {
4949+ try {
5050+ const response = await fetch(
5151+ `https://dns.google/resolve?name=_atproto.${domain}&type=TXT`
5252+ )
5353+ const data = await response.json()
5454+5555+ // We use the TXT record type to avoid CORS issues
5656+ const records = data?.Answer?.filter((record) => record.type === 16) || []
5757+5858+ // We filter out all records that are not TXT records
5959+ const didRecord = records.find((record) =>
6060+ record.data.includes("did=did:plc:")
6161+ )
6262+6363+ // We return the DID if we found one and it's valid
6464+ return didRecord && isValidDID(didRecord.data.replace("did=", ""))
6565+ ? didRecord.data.replace("did=", "")
6666+ : null
6767+ } catch (error) {
6868+ return null
6969+ }
7070+}
9711010-// On extension installation, check if privacy consent was already accepted and show it if not
1111-runtime.onInstalled.addListener(() => {
1212- storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => {
1313- if (
1414- typeof privacyConsentAccepted === "undefined" ||
1515- !privacyConsentAccepted
1616- ) {
1717- tabs.create({ url: "privacy_consent.html" })
7272+// Function to check for a DID in the well-known (not .well-known) location
7373+async function checkForDIDHTTPS(domain) {
7474+ try {
7575+ const response = await fetch(
7676+ `https://${domain}/xrpc/com.atproto.identity.resolveHandle`
7777+ )
7878+7979+ if (!response.headers.get("Content-Type")?.includes("application/json")) {
8080+ throw new Error("Invalid Content-Type")
1881 }
1919- })
2020-})
2121-2222-// If the message 'SHOW_CONSENT' is received, open the privacy consent tab
2323-runtime.onMessage.addListener((message) => {
2424- if (message.type === "SHOW_CONSENT") {
2525- tabs.create({ url: "privacy_consent.html" })
8282+ const data = await response.json()
8383+ return data.did && isValidDID(data.did) ? data.did : null
8484+ } catch (error) {
8585+ return null
2686 }
2727-})
8787+}
28882989// Map to store tabs with DIDs
3090const tabsWithDID = new Map()
31913292// URL of the Bluesky Web Applications
3333-const bskyAppUrl = "https://staging.bsky.app"
9393+const bskyAppUrl = "https://bsky.app"
34943595// Function to set the extension icon
3696function setIcon(tabId, iconName) {
3797 action.setIcon({ path: iconName, tabId })
3898}
3999100100+// Cache for storing domain DIDs
101101+// We use caching to prevent creating multiple requests
102102+// for a tab/domain that has already returned a check
103103+// The cache is cleared when the tab is closed
104104+const didCache = new Map()
105105+106106+function performAction(tab) {
107107+ storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => {
108108+ if (privacyConsentAccepted) {
109109+ const domain = getDomainName(tab.url)
110110+ if (isValidDomain(domain)) {
111111+ // Check if we have cached DID for this tab and domain
112112+ const cachedDID = didCache.get(`${tab.id}:${domain}`)
113113+ if (cachedDID !== undefined) {
114114+ // If we have a cached DID or a cached "not found" state, use it
115115+ if (cachedDID !== null) {
116116+ setDID(tab, cachedDID)
117117+ } else {
118118+ setIcon(tab.id, "logo48_gray.png")
119119+ tabsWithDID.delete(tab.id)
120120+ }
121121+ } else {
122122+ // If not, proceed with the checks
123123+ checkForDIDDNS(domain).then((domainDID) => {
124124+ if (domainDID) {
125125+ setDID(tab, domainDID)
126126+ didCache.set(`${tab.id}:${domain}`, domainDID)
127127+ } else {
128128+ checkForDIDHTTPS(domain).then((httpsDID) => {
129129+ if (httpsDID) {
130130+ setDID(tab, httpsDID)
131131+ didCache.set(`${tab.id}:${domain}`, httpsDID)
132132+ } else {
133133+ setIcon(tab.id, "logo48_gray.png")
134134+ tabsWithDID.delete(tab.id)
135135+ // Cache the "not found" state
136136+ didCache.set(`${tab.id}:${domain}`, null)
137137+ }
138138+ })
139139+ }
140140+ })
141141+ }
142142+ }
143143+ }
144144+ })
145145+}
146146+147147+// Function to set the DID
148148+function setDID(tab, did) {
149149+ setIcon(tab.id, "logo48.png")
150150+ tabsWithDID.set(tab.id, did)
151151+}
152152+153153+// Execute performAction when a tab is updated and the tab is a website.
154154+tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
155155+ if (
156156+ changeInfo.status === "loading" &&
157157+ tab.active &&
158158+ (tab.url.startsWith("http://") || tab.url.startsWith("https://"))
159159+ ) {
160160+ // Get the old domain from the cache
161161+ const oldDomain = Array.from(didCache.keys())
162162+ .filter((key) => key.startsWith(`${tabId}:`))
163163+ .map((key) => key.split(":")[1])[0]
164164+165165+ // Get the new domain
166166+ const newDomain = getDomainName(tab.url)
167167+168168+ // If the domain has changed, clear the DID state for this tab
169169+ if (newDomain !== oldDomain) {
170170+ didCache.delete(`${tabId}:${oldDomain}`)
171171+ }
172172+ // Perform the action
173173+ performAction(tab)
174174+ }
175175+})
176176+177177+// On extension installation, check if privacy consent was already accepted and show it if not
178178+runtime.onInstalled.addListener(() => {
179179+ storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => {
180180+ if (
181181+ typeof privacyConsentAccepted === "undefined" ||
182182+ !privacyConsentAccepted
183183+ ) {
184184+ tabs.create({ url: "privacy_consent.html" })
185185+ }
186186+ })
187187+})
188188+40189// On extension installation, set the icon to gray for all tabs
41190runtime.onInstalled.addListener(() => {
42191 tabs.query({}, (tabs) => {
43192 tabs.forEach((tab) => setIcon(tab.id, "logo48_gray.png"))
44193 })
4545-})
4646-4747-// When a message is received from the DNS check, set the icon color to blue.
4848-runtime.onMessage.addListener((message, sender) => {
4949- if (message.type === "DID_FOUND") {
5050- setIcon(sender.tab.id, "logo48.png")
5151- tabsWithDID.set(sender.tab.id, message.did)
5252- } else {
5353- setIcon(sender.tab.id, "logo48_gray.png")
5454- tabsWithDID.delete(sender.tab.id)
5555- }
56194})
5719558196// Open the consent page if it hasn't been accepted and the user clicks on the extension icon
-83
content.js
···11-// Set up cross-browser compatibility
22-const runtime =
33- typeof browser !== "undefined" ? browser.runtime : chrome.runtime
44-const storage =
55- typeof browser !== "undefined" ? browser.storage.local : chrome.storage.local
66-77-// Function to get the domain name from the current hostname
88-function getDomainName() {
99- const hostname = window.location.hostname
1010- return hostname.replace(/^www\./, "")
1111-}
1212-1313-// Function to check for a DID in the domain's TXT records
1414-async function checkForDIDDNS(domain) {
1515- // We use Google's DNS over HTTPS API to resolve the TXT record
1616- const response = await fetch(
1717- `https://dns.google/resolve?name=_atproto.${domain}&type=TXT`
1818- )
1919- const data = await response.json()
2020-2121- // We use the TXT record type to avoid CORS issues
2222- const records = data?.Answer?.filter((record) => record.type === 16) || []
2323-2424- // We filter out all records that are not TXT records
2525- const didRecord = records.find((record) =>
2626- record.data.includes("did=did:plc:")
2727- )
2828-2929- // We return the DID if we found one
3030- return didRecord ? didRecord.data.replace("did=", "") : null
3131-}
3232-3333-// Function to check for a DID in the well-known (not .well-known) location
3434-async function checkForDIDHTTPS(domain) {
3535- try {
3636- const response = await fetch(
3737- `https://${domain}/xrpc/com.atproto.identity.resolveHandle`
3838- )
3939- const data = await response.json()
4040- return data.did
4141- } catch (error) {
4242- return null
4343- }
4444-}
4545-4646-// Main function to perform actions, but only if the privacy consent has been accepted
4747-function performAction(privacyConsentAccepted) {
4848- // If the user has accepted the privacy consent
4949- if (privacyConsentAccepted) {
5050- // We check for a DID on the current domain
5151- ;(async function () {
5252- const domain = getDomainName()
5353- const domainDID = await checkForDIDDNS(domain)
5454- const httpsDID = await checkForDIDHTTPS(domain)
5555-5656- if (domainDID) {
5757- runtime.sendMessage({ type: "DID_FOUND", did: domainDID })
5858- } else if (httpsDID) {
5959- runtime.sendMessage({ type: "DID_FOUND", did: httpsDID })
6060- } else {
6161- runtime.sendMessage({ type: "DID_NOT_FOUND" })
6262- }
6363- })()
6464-6565- // We listen for messages from the background script
6666- runtime.onMessage.addListener((message, sender, sendResponse) => {
6767- if (message.type === "GET_DID") {
6868- checkForDIDDNS(getDomainName())
6969- .then((did) => sendResponse({ did }))
7070- .catch(() => sendResponse({ did: null }))
7171- return true // Indicate that the response will be sent asynchronously.
7272- }
7373- })
7474- } else {
7575- // Do nothing since the consent form has not been accepted.
7676- return
7777- }
7878-}
7979-8080-// Get the user's privacy consent from the storage and perform actions accordingly
8181-storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => {
8282- performAction(privacyConsentAccepted)
8383-})