this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #23 from sanitybit/sanitybit/did-validation

authored by

Jesse J. Anderson and committed by
GitHub
d7af7900 a698dd56

+190 -133
+7
CHANGELOG.md
··· 9 9 10 10 ## [Unreleased] 11 11 12 + - Migrate/refactor everything from `content.js` into a self-contained `background.js` 13 + - Swap `activeTab` permission for `tabs` so that we can drop the `<all_urls>` permission 14 + - Remove permissions for "management" as it is not needed 15 + - Migrate Firefox to Manifest v3 (but this means the minimum FF version is now 109) 16 + - Input validation for domains and DIDs (security enhancement) 17 + - Replace `staging.bsky.app` with `bsky.app` 18 + 12 19 ## [1.3.0] 13 20 14 21 ### Added
+4 -4
README.md
··· 15 15 16 16 --- 17 17 18 - As of April 30, 2023, Bluesky is still invite-only and the web app is at https://staging.bsky.app. 18 + As of May 17, 2023, Bluesky is still invite-only and the web app is at https://bsky.app. 19 19 20 - You can find me there at [@adhdjesse.com](https://staging.bsky.app/profile/adhdjesse.com) 20 + You can find me there at [@adhdjesse.com](https://bsky.app/profile/adhdjesse.com) 21 21 22 22 **Contributors:** 23 23 24 - [@danielhuckmann.com](https://staging.bsky.app/profile/danielhuckmann.com) - Firefox Support & Privacy Consent 25 - [@aliceisjustplaying](https://staging.bsky.app/profile/alice.bsky.sh) - HTTPS Method of DID Detection 24 + - [@danielhuckmann.com](https://bsky.app/profile/danielhuckmann.com) - Firefox Support, Privacy & Security Enhancements 25 + - [@aliceisjustplaying](https://bsky.app/profile/alice.bsky.sh) - HTTPS Method of DID Detection
+168 -30
background.js
··· 4 4 const tabs = typeof browser !== "undefined" ? browser.tabs : chrome.tabs 5 5 const storage = 6 6 typeof browser !== "undefined" ? browser.storage.local : chrome.storage.local 7 - const action = 8 - typeof browser !== "undefined" ? browser.browserAction : chrome.action 7 + const action = typeof browser !== "undefined" ? browser.action : chrome.action 8 + 9 + // Make sure that we don't DoS the regex if someone supplies too large of a DID 10 + const MAX_DID_LENGTH = 255 11 + 12 + // Regular expression to validate the DID format 13 + // https://w3c.github.io/did-core/#did-syntax 14 + const didRegex = 15 + /^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._-])+)*$)/ 16 + 17 + // Function to validate the DID string 18 + function isValidDID(didString) { 19 + return didString.length <= MAX_DID_LENGTH && didRegex.test(didString) 20 + } 21 + 22 + // Function to get the domain name from the current hostname 23 + function getDomainName(url) { 24 + const hostname = new URL(url).hostname 25 + return hostname.replace(/^www\./, "") 26 + } 27 + 28 + // Function to validate the domain name 29 + function isValidDomain(domain) { 30 + const MAX_DOMAIN_LENGTH = 255 31 + 32 + if (domain.length > MAX_DOMAIN_LENGTH) { 33 + return false 34 + } 35 + 36 + try { 37 + // Use the build in URL constructor to validate the URL, if doesn't throw an error, the domain is valid 38 + // This is a better choice than a regex since it should properly support punycode/international domains 39 + new URL(`https://${domain}`) 40 + return true 41 + } catch (error) { 42 + // The URL constructor threw an error, so the domain is not valid 43 + return false 44 + } 45 + } 46 + 47 + // Function to check for a DID in the domain's TXT records 48 + async function checkForDIDDNS(domain) { 49 + try { 50 + const response = await fetch( 51 + `https://dns.google/resolve?name=_atproto.${domain}&type=TXT` 52 + ) 53 + const data = await response.json() 54 + 55 + // We use the TXT record type to avoid CORS issues 56 + const records = data?.Answer?.filter((record) => record.type === 16) || [] 57 + 58 + // We filter out all records that are not TXT records 59 + const didRecord = records.find((record) => 60 + record.data.includes("did=did:plc:") 61 + ) 62 + 63 + // We return the DID if we found one and it's valid 64 + return didRecord && isValidDID(didRecord.data.replace("did=", "")) 65 + ? didRecord.data.replace("did=", "") 66 + : null 67 + } catch (error) { 68 + return null 69 + } 70 + } 9 71 10 - // On extension installation, check if privacy consent was already accepted and show it if not 11 - runtime.onInstalled.addListener(() => { 12 - storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => { 13 - if ( 14 - typeof privacyConsentAccepted === "undefined" || 15 - !privacyConsentAccepted 16 - ) { 17 - tabs.create({ url: "privacy_consent.html" }) 72 + // Function to check for a DID in the well-known (not .well-known) location 73 + async function checkForDIDHTTPS(domain) { 74 + try { 75 + const response = await fetch( 76 + `https://${domain}/xrpc/com.atproto.identity.resolveHandle` 77 + ) 78 + 79 + if (!response.headers.get("Content-Type")?.includes("application/json")) { 80 + throw new Error("Invalid Content-Type") 18 81 } 19 - }) 20 - }) 21 - 22 - // If the message 'SHOW_CONSENT' is received, open the privacy consent tab 23 - runtime.onMessage.addListener((message) => { 24 - if (message.type === "SHOW_CONSENT") { 25 - tabs.create({ url: "privacy_consent.html" }) 82 + const data = await response.json() 83 + return data.did && isValidDID(data.did) ? data.did : null 84 + } catch (error) { 85 + return null 26 86 } 27 - }) 87 + } 28 88 29 89 // Map to store tabs with DIDs 30 90 const tabsWithDID = new Map() 31 91 32 92 // URL of the Bluesky Web Applications 33 - const bskyAppUrl = "https://staging.bsky.app" 93 + const bskyAppUrl = "https://bsky.app" 34 94 35 95 // Function to set the extension icon 36 96 function setIcon(tabId, iconName) { 37 97 action.setIcon({ path: iconName, tabId }) 38 98 } 39 99 100 + // Cache for storing domain DIDs 101 + // We use caching to prevent creating multiple requests 102 + // for a tab/domain that has already returned a check 103 + // The cache is cleared when the tab is closed 104 + const didCache = new Map() 105 + 106 + function performAction(tab) { 107 + storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => { 108 + if (privacyConsentAccepted) { 109 + const domain = getDomainName(tab.url) 110 + if (isValidDomain(domain)) { 111 + // Check if we have cached DID for this tab and domain 112 + const cachedDID = didCache.get(`${tab.id}:${domain}`) 113 + if (cachedDID !== undefined) { 114 + // If we have a cached DID or a cached "not found" state, use it 115 + if (cachedDID !== null) { 116 + setDID(tab, cachedDID) 117 + } else { 118 + setIcon(tab.id, "logo48_gray.png") 119 + tabsWithDID.delete(tab.id) 120 + } 121 + } else { 122 + // If not, proceed with the checks 123 + checkForDIDDNS(domain).then((domainDID) => { 124 + if (domainDID) { 125 + setDID(tab, domainDID) 126 + didCache.set(`${tab.id}:${domain}`, domainDID) 127 + } else { 128 + checkForDIDHTTPS(domain).then((httpsDID) => { 129 + if (httpsDID) { 130 + setDID(tab, httpsDID) 131 + didCache.set(`${tab.id}:${domain}`, httpsDID) 132 + } else { 133 + setIcon(tab.id, "logo48_gray.png") 134 + tabsWithDID.delete(tab.id) 135 + // Cache the "not found" state 136 + didCache.set(`${tab.id}:${domain}`, null) 137 + } 138 + }) 139 + } 140 + }) 141 + } 142 + } 143 + } 144 + }) 145 + } 146 + 147 + // Function to set the DID 148 + function setDID(tab, did) { 149 + setIcon(tab.id, "logo48.png") 150 + tabsWithDID.set(tab.id, did) 151 + } 152 + 153 + // Execute performAction when a tab is updated and the tab is a website. 154 + tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 155 + if ( 156 + changeInfo.status === "loading" && 157 + tab.active && 158 + (tab.url.startsWith("http://") || tab.url.startsWith("https://")) 159 + ) { 160 + // Get the old domain from the cache 161 + const oldDomain = Array.from(didCache.keys()) 162 + .filter((key) => key.startsWith(`${tabId}:`)) 163 + .map((key) => key.split(":")[1])[0] 164 + 165 + // Get the new domain 166 + const newDomain = getDomainName(tab.url) 167 + 168 + // If the domain has changed, clear the DID state for this tab 169 + if (newDomain !== oldDomain) { 170 + didCache.delete(`${tabId}:${oldDomain}`) 171 + } 172 + // Perform the action 173 + performAction(tab) 174 + } 175 + }) 176 + 177 + // On extension installation, check if privacy consent was already accepted and show it if not 178 + runtime.onInstalled.addListener(() => { 179 + storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => { 180 + if ( 181 + typeof privacyConsentAccepted === "undefined" || 182 + !privacyConsentAccepted 183 + ) { 184 + tabs.create({ url: "privacy_consent.html" }) 185 + } 186 + }) 187 + }) 188 + 40 189 // On extension installation, set the icon to gray for all tabs 41 190 runtime.onInstalled.addListener(() => { 42 191 tabs.query({}, (tabs) => { 43 192 tabs.forEach((tab) => setIcon(tab.id, "logo48_gray.png")) 44 193 }) 45 - }) 46 - 47 - // When a message is received from the DNS check, set the icon color to blue. 48 - runtime.onMessage.addListener((message, sender) => { 49 - if (message.type === "DID_FOUND") { 50 - setIcon(sender.tab.id, "logo48.png") 51 - tabsWithDID.set(sender.tab.id, message.did) 52 - } else { 53 - setIcon(sender.tab.id, "logo48_gray.png") 54 - tabsWithDID.delete(sender.tab.id) 55 - } 56 194 }) 57 195 58 196 // Open the consent page if it hasn't been accepted and the user clicks on the extension icon
-83
content.js
··· 1 - // Set up cross-browser compatibility 2 - const runtime = 3 - typeof browser !== "undefined" ? browser.runtime : chrome.runtime 4 - const storage = 5 - typeof browser !== "undefined" ? browser.storage.local : chrome.storage.local 6 - 7 - // Function to get the domain name from the current hostname 8 - function getDomainName() { 9 - const hostname = window.location.hostname 10 - return hostname.replace(/^www\./, "") 11 - } 12 - 13 - // Function to check for a DID in the domain's TXT records 14 - async function checkForDIDDNS(domain) { 15 - // We use Google's DNS over HTTPS API to resolve the TXT record 16 - const response = await fetch( 17 - `https://dns.google/resolve?name=_atproto.${domain}&type=TXT` 18 - ) 19 - const data = await response.json() 20 - 21 - // We use the TXT record type to avoid CORS issues 22 - const records = data?.Answer?.filter((record) => record.type === 16) || [] 23 - 24 - // We filter out all records that are not TXT records 25 - const didRecord = records.find((record) => 26 - record.data.includes("did=did:plc:") 27 - ) 28 - 29 - // We return the DID if we found one 30 - return didRecord ? didRecord.data.replace("did=", "") : null 31 - } 32 - 33 - // Function to check for a DID in the well-known (not .well-known) location 34 - async function checkForDIDHTTPS(domain) { 35 - try { 36 - const response = await fetch( 37 - `https://${domain}/xrpc/com.atproto.identity.resolveHandle` 38 - ) 39 - const data = await response.json() 40 - return data.did 41 - } catch (error) { 42 - return null 43 - } 44 - } 45 - 46 - // Main function to perform actions, but only if the privacy consent has been accepted 47 - function performAction(privacyConsentAccepted) { 48 - // If the user has accepted the privacy consent 49 - if (privacyConsentAccepted) { 50 - // We check for a DID on the current domain 51 - ;(async function () { 52 - const domain = getDomainName() 53 - const domainDID = await checkForDIDDNS(domain) 54 - const httpsDID = await checkForDIDHTTPS(domain) 55 - 56 - if (domainDID) { 57 - runtime.sendMessage({ type: "DID_FOUND", did: domainDID }) 58 - } else if (httpsDID) { 59 - runtime.sendMessage({ type: "DID_FOUND", did: httpsDID }) 60 - } else { 61 - runtime.sendMessage({ type: "DID_NOT_FOUND" }) 62 - } 63 - })() 64 - 65 - // We listen for messages from the background script 66 - runtime.onMessage.addListener((message, sender, sendResponse) => { 67 - if (message.type === "GET_DID") { 68 - checkForDIDDNS(getDomainName()) 69 - .then((did) => sendResponse({ did })) 70 - .catch(() => sendResponse({ did: null })) 71 - return true // Indicate that the response will be sent asynchronously. 72 - } 73 - }) 74 - } else { 75 - // Do nothing since the consent form has not been accepted. 76 - return 77 - } 78 - } 79 - 80 - // Get the user's privacy consent from the storage and perform actions accordingly 81 - storage.get("privacyConsentAccepted", ({ privacyConsentAccepted }) => { 82 - performAction(privacyConsentAccepted) 83 - })
+8 -12
manifest-firefox.json
··· 1 1 { 2 - "manifest_version": 2, 2 + "manifest_version": 3, 3 3 "name": "SkyLink - Bluesky DID Detector", 4 4 "short_name": "SkyLink", 5 - "version": "1.3.0", 5 + "version": "1.4.0", 6 6 "author": "jesse@adhdjesse.com", 7 - "browser_action": { 7 + "action": { 8 8 "default_icon": { 9 9 "48": "logo48_gray.png", 10 10 "128": "logo128_gray.png" ··· 15 15 "128": "logo128.png" 16 16 }, 17 17 "description": "Detects Decentralized Identifiers (DIDs) in a domain's TXT records and links to the associated Bluesky profile.", 18 - "permissions": ["activeTab", "management", "storage", "<all_urls>"], 19 - "content_security_policy": "script-src 'self'; object-src 'self'", 18 + "permissions": ["tabs", "storage"], 19 + "content_security_policy": { 20 + "extension_pages": "script-src 'self'; object-src 'self'" 21 + }, 20 22 "background": { 21 23 "scripts": ["background.js"] 22 24 }, 23 - "content_scripts": [ 24 - { 25 - "matches": ["<all_urls>"], 26 - "js": ["content.js"] 27 - } 28 - ], 29 25 "browser_specific_settings": { 30 26 "gecko": { 31 27 "id": "jesse@adhdjesse.com", 32 - "strict_min_version": "79.0" 28 + "strict_min_version": "109.0" 33 29 } 34 30 } 35 31 }
+3 -4
manifest.json
··· 2 2 "manifest_version": 3, 3 3 "name": "SkyLink - Bluesky DID Detector", 4 4 "short_name": "SkyLink", 5 - "version": "1.3.0", 5 + "version": "1.4.0", 6 6 "author": "jesse@adhdjesse.com", 7 7 "action": { 8 8 "default_icon": { "48": "logo48_gray.png", "128": "logo128_gray.png" } 9 9 }, 10 10 "icons": { "48": "logo48.png", "128": "logo128.png" }, 11 11 "description": "Detects Decentralized Identifiers (DIDs) in a domain's TXT records and links to the associated Bluesky profile.", 12 - "permissions": ["activeTab", "storage", "management"], 13 - "background": { "service_worker": "background.js" }, 14 - "content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"] }] 12 + "permissions": ["tabs", "storage"], 13 + "background": { "service_worker": "background.js" } 15 14 }