-
Notifications
You must be signed in to change notification settings - Fork 0
[fix] Security update #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,55 @@ const api = typeof browser !== "undefined" ? browser : chrome; | |
| // ============================================================================= | ||
|
|
||
| const FEEDLY_API_BASE = "https://api.feedly.com"; | ||
| const TOKEN_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes | ||
|
|
||
| // ============================================================================= | ||
| // Error Handling | ||
| // ============================================================================= | ||
|
|
||
| const ErrorCode = { | ||
| NO_TOKEN: 'NO_TOKEN', | ||
| AUTH_FAILED: 'AUTH_FAILED', | ||
| RATE_LIMITED: 'RATE_LIMITED', | ||
| NETWORK_ERROR: 'NETWORK_ERROR', | ||
| SERVER_ERROR: 'SERVER_ERROR', | ||
| WRONG_PAGE: 'WRONG_PAGE', | ||
| UNKNOWN: 'UNKNOWN' | ||
| }; | ||
|
|
||
| const UserMessages = { | ||
| NO_TOKEN: "Please sign in to Feedly first.", | ||
| AUTH_FAILED: "Session expired. Please refresh the page.", | ||
|
maro114510 marked this conversation as resolved.
Outdated
|
||
| RATE_LIMITED: "Too many requests. Please wait a moment.", | ||
| NETWORK_ERROR: "Network error. Please check your connection.", | ||
| SERVER_ERROR: "Feedly service is temporarily unavailable.", | ||
| WRONG_PAGE: "Please open a Feedly Read Later page.", | ||
| UNKNOWN: "Something went wrong. Please try again." | ||
| }; | ||
|
|
||
| class FeedlyError extends Error { | ||
| constructor(code, technicalDetail) { | ||
| super(technicalDetail); | ||
| this.name = 'FeedlyError'; | ||
| this.code = code; | ||
| this.userMessage = UserMessages[code] || UserMessages.UNKNOWN; | ||
| } | ||
|
|
||
| getUserMessage() { | ||
| return this.userMessage; | ||
| } | ||
|
|
||
| getDebugInfo() { | ||
| return `[${this.code}] ${this.message}`; | ||
| } | ||
| } | ||
|
|
||
| function classifyHttpError(status) { | ||
| if (status === 401 || status === 403) return ErrorCode.AUTH_FAILED; | ||
| if (status === 429) return ErrorCode.RATE_LIMITED; | ||
| if (status >= 500) return ErrorCode.SERVER_ERROR; | ||
| return ErrorCode.UNKNOWN; | ||
|
Comment on lines
+53
to
+58
|
||
| } | ||
|
maro114510 marked this conversation as resolved.
|
||
|
|
||
| // ============================================================================= | ||
| // Constants | ||
|
|
@@ -55,15 +104,67 @@ function normalizeCount(count) { | |
| // Feedly API Functions | ||
| // ============================================================================= | ||
|
|
||
| // Token storage for communication between page and content script | ||
| let cachedAccessToken = null; | ||
| // Token storage with metadata for TTL and change detection | ||
| let tokenCache = { | ||
| token: null, | ||
| cachedAt: 0, | ||
| sourceHash: null | ||
| }; | ||
|
|
||
| /** | ||
| * Generate a simple hash for change detection (djb2 algorithm). | ||
| * @param {string} str - String to hash | ||
| * @returns {number} Simple numeric hash | ||
| */ | ||
| function simpleHash(str) { | ||
| if (!str) return 0; | ||
| let hash = 0; | ||
| for (let i = 0; i < str.length; i++) { | ||
| const char = str.charCodeAt(i); | ||
| hash = ((hash << 5) - hash) + char; | ||
| hash = hash & hash; | ||
|
maro114510 marked this conversation as resolved.
Outdated
|
||
| } | ||
| return hash; | ||
| } | ||
|
|
||
| /** | ||
| * Check if cached token is still valid. | ||
| * Validates TTL expiration and localStorage data integrity. | ||
| * @returns {boolean} True if cache is valid | ||
| */ | ||
| function isTokenCacheValid() { | ||
| if (!tokenCache.token) { | ||
| return false; | ||
| } | ||
|
|
||
| // Check TTL expiration | ||
| if (Date.now() - tokenCache.cachedAt > TOKEN_CACHE_TTL_MS) { | ||
| return false; | ||
| } | ||
|
|
||
| // Validate against current localStorage | ||
| try { | ||
| const currentSessionData = localStorage.getItem("feedly.session"); | ||
| if (simpleHash(currentSessionData) !== tokenCache.sourceHash) { | ||
| return false; | ||
|
Comment on lines
+151
to
+152
|
||
| } | ||
| } catch (e) { | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Clear the cached access token. | ||
| * Called when authentication fails to allow re-fetching from localStorage. | ||
| */ | ||
| function clearAccessTokenCache() { | ||
| cachedAccessToken = null; | ||
| tokenCache = { | ||
| token: null, | ||
| cachedAt: 0, | ||
| sourceHash: null | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -77,18 +178,25 @@ function clearAccessTokenCache() { | |
| * @returns {Promise<string|null>} Access token or null if not available | ||
| */ | ||
| async function getAccessToken() { | ||
| // Return cached token if available | ||
| if (cachedAccessToken) { | ||
| return cachedAccessToken; | ||
| // Return cached token if still valid | ||
| if (isTokenCacheValid()) { | ||
| return tokenCache.token; | ||
| } | ||
|
|
||
| // Clear expired/invalid cache | ||
| clearAccessTokenCache(); | ||
|
|
||
| try { | ||
| const sessionData = localStorage.getItem("feedly.session"); | ||
| if (sessionData) { | ||
| const session = JSON.parse(sessionData); | ||
| if (session.feedlyToken) { | ||
| cachedAccessToken = session.feedlyToken; | ||
| return cachedAccessToken; | ||
| tokenCache = { | ||
| token: session.feedlyToken, | ||
| cachedAt: Date.now(), | ||
| sourceHash: simpleHash(sessionData) | ||
| }; | ||
| return tokenCache.token; | ||
| } | ||
| } | ||
| } catch (e) { | ||
|
|
@@ -107,26 +215,38 @@ async function getAccessToken() { | |
| async function feedlyApiRequest(endpoint, options = {}) { | ||
| const token = await getAccessToken(); | ||
| if (!token) { | ||
| throw new Error("No Feedly access token available"); | ||
| throw new FeedlyError(ErrorCode.NO_TOKEN, 'No access token in localStorage'); | ||
| } | ||
|
|
||
| const url = `${FEEDLY_API_BASE}${endpoint}`; | ||
| const response = await fetch(url, { | ||
| ...options, | ||
| headers: { | ||
| "Authorization": `Bearer ${token}`, | ||
| "Content-Type": "application/json", | ||
| ...options.headers | ||
| } | ||
| }); | ||
|
|
||
| let response; | ||
| try { | ||
| response = await fetch(url, { | ||
| ...options, | ||
| headers: { | ||
| "Authorization": `Bearer ${token}`, | ||
| "Content-Type": "application/json", | ||
| ...options.headers | ||
| } | ||
| }); | ||
| } catch (networkError) { | ||
| throw new FeedlyError( | ||
| ErrorCode.NETWORK_ERROR, | ||
| `Fetch failed: ${networkError.message}` | ||
| ); | ||
| } | ||
|
|
||
| if (!response.ok) { | ||
| // Clear token cache on authentication errors to allow retry with fresh token | ||
| if (response.status === 401 || response.status === 403) { | ||
| clearAccessTokenCache(); | ||
| } | ||
| const errorText = await response.text().catch(() => "Unknown error"); | ||
| throw new Error(`Feedly API error ${response.status}: ${errorText}`); | ||
| const errorBody = await response.text().catch(() => ""); | ||
| throw new FeedlyError( | ||
| classifyHttpError(response.status), | ||
| `API ${response.status}: ${errorBody.substring(0, 200)}` | ||
| ); | ||
| } | ||
|
|
||
| // DELETE requests may return empty body | ||
|
||
|
|
@@ -244,7 +364,7 @@ async function unsaveEntriesViaAPI(userId, entryIds) { | |
| async function handleOpenViaAPI(settings) { | ||
| const token = await getAccessToken(); | ||
| if (!token) { | ||
| throw new Error("No access token available. Please ensure you are logged into Feedly."); | ||
| throw new FeedlyError(ErrorCode.NO_TOKEN, 'No access token available'); | ||
| } | ||
|
maro114510 marked this conversation as resolved.
Outdated
|
||
|
|
||
| const userId = await getUserId(); | ||
|
|
@@ -605,7 +725,7 @@ function findScrollContainer(startNode) { | |
| */ | ||
| async function handleOpenViaDOM(settings) { | ||
| if (!(await waitForReadLaterPage(2000))) { | ||
| return { ok: false, error: "This tab is not a Feedly Read Later page." }; | ||
| throw new FeedlyError(ErrorCode.WRONG_PAGE, `Not on Read Later page: ${location.href}`); | ||
| } | ||
|
|
||
| if (settings.mode === "all") { | ||
|
|
@@ -639,22 +759,33 @@ async function handleOpen(settings) { | |
| result = await handleOpenViaAPI(settings); | ||
| } catch (e) { | ||
| apiError = e; | ||
| console.warn("[Feedly Opener] API operation failed, falling back to DOM:", e.message); | ||
| if (e instanceof FeedlyError) { | ||
| console.warn("[Feedly Opener] API failed:", e.getDebugInfo()); | ||
| } else { | ||
| console.warn("[Feedly Opener] API failed:", e.message); | ||
| } | ||
| } | ||
|
|
||
| // Fallback to DOM-based approach if API failed | ||
| if (!result || !result.ok) { | ||
| try { | ||
| result = await handleOpenViaDOM(settings); | ||
| if (apiError) { | ||
| result.apiError = apiError.message; | ||
| } | ||
| } catch (domError) { | ||
| console.error("[Feedly Opener] DOM operation also failed:", domError); | ||
| if (domError instanceof FeedlyError) { | ||
| console.error("[Feedly Opener] DOM failed:", domError.getDebugInfo()); | ||
| } else { | ||
| console.error("[Feedly Opener] DOM failed:", domError.message); | ||
| } | ||
|
|
||
| const userMessage = apiError instanceof FeedlyError | ||
| ? apiError.getUserMessage() | ||
| : UserMessages.UNKNOWN; | ||
|
maro114510 marked this conversation as resolved.
Outdated
|
||
|
|
||
| return { | ||
| ok: false, | ||
| error: `API error: ${apiError?.message || "unknown"}. DOM error: ${domError.message}`, | ||
| method: "failed" | ||
| error: userMessage, | ||
| method: "failed", | ||
| errorCode: apiError instanceof FeedlyError ? apiError.code : ErrorCode.UNKNOWN | ||
|
maro114510 marked this conversation as resolved.
Outdated
|
||
| }; | ||
|
maro114510 marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
@@ -706,6 +837,33 @@ function isRecentlyReadLater() { | |
| return location.origin === "https://feedly.com" && lastReadLaterUrl.length > 0; | ||
| } | ||
|
|
||
| // ============================================================================= | ||
| // Message Security | ||
| // ============================================================================= | ||
|
|
||
| /** | ||
| * Validate message sender is from our own extension. | ||
| * @param {Object} sender - Message sender object | ||
| * @returns {boolean} True if sender is valid | ||
| */ | ||
| function validateSender(sender) { | ||
| return sender && sender.id === api.runtime.id; | ||
| } | ||
|
maro114510 marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Validate and sanitize settings from message. | ||
| * @param {Object} raw - Raw settings object from message | ||
| * @returns {Object} Validated settings with safe defaults | ||
| */ | ||
| function validateSettings(raw) { | ||
| const validModes = ['all', 'count']; | ||
| return { | ||
| mode: validModes.includes(raw?.mode) ? raw.mode : 'all', | ||
| count: Math.max(1, Math.min(999, Math.floor(Number(raw?.count)) || 10)), | ||
|
maro114510 marked this conversation as resolved.
Outdated
maro114510 marked this conversation as resolved.
Outdated
|
||
| reload: typeof raw?.reload === 'boolean' ? raw.reload : true | ||
|
maro114510 marked this conversation as resolved.
Outdated
|
||
| }; | ||
|
maro114510 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // ============================================================================= | ||
| // Message Listener | ||
| // ============================================================================= | ||
|
|
@@ -717,7 +875,22 @@ if (!window.__feedlyReadLaterOpenerListenerAdded) { | |
| return false; | ||
| } | ||
|
|
||
| handleOpen(message.settings || {}).then(sendResponse); | ||
| // Validate sender is from our own extension | ||
| if (!validateSender(sender)) { | ||
| sendResponse({ ok: false, error: "Invalid message sender" }); | ||
| return true; | ||
| } | ||
|
|
||
| // Validate and sanitize settings | ||
| const settings = validateSettings(message.settings); | ||
|
|
||
| handleOpen(settings) | ||
| .then(sendResponse) | ||
| .catch((e) => { | ||
| console.error("[Feedly Opener]", e); | ||
| const userMessage = e instanceof FeedlyError ? e.getUserMessage() : UserMessages.UNKNOWN; | ||
| sendResponse({ ok: false, error: userMessage }); | ||
| }); | ||
|
maro114510 marked this conversation as resolved.
|
||
| return true; | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.