Skip to content

Commit fd046f5

Browse files
authored
Merge pull request #5 from maro114510/fix/security-update
[fix] Security update
2 parents 41a1040 + 4684368 commit fd046f5

2 files changed

Lines changed: 231 additions & 38 deletions

File tree

content.js

Lines changed: 227 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,58 @@ const api = typeof browser !== "undefined" ? browser : chrome;
55
// =============================================================================
66

77
const FEEDLY_API_BASE = "https://api.feedly.com";
8+
const TOKEN_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
9+
10+
// =============================================================================
11+
// Error Handling
12+
// =============================================================================
13+
14+
const ErrorCode = {
15+
NO_TOKEN: 'NO_TOKEN',
16+
AUTH_FAILED: 'AUTH_FAILED',
17+
RATE_LIMITED: 'RATE_LIMITED',
18+
NETWORK_ERROR: 'NETWORK_ERROR',
19+
SERVER_ERROR: 'SERVER_ERROR',
20+
CLIENT_ERROR: 'CLIENT_ERROR',
21+
WRONG_PAGE: 'WRONG_PAGE',
22+
UNKNOWN: 'UNKNOWN'
23+
};
24+
25+
const UserMessages = {
26+
NO_TOKEN: "Please sign in to Feedly first.",
27+
AUTH_FAILED: "Authentication failed. Please sign in to Feedly again.",
28+
RATE_LIMITED: "Too many requests. Please wait a moment.",
29+
NETWORK_ERROR: "Network error. Please check your connection.",
30+
SERVER_ERROR: "Feedly service is temporarily unavailable.",
31+
CLIENT_ERROR: "Invalid request. Please try again.",
32+
WRONG_PAGE: "Please open a Feedly Read Later page.",
33+
UNKNOWN: "Something went wrong. Please try again."
34+
};
35+
36+
class FeedlyError extends Error {
37+
constructor(code, technicalDetail) {
38+
super(technicalDetail);
39+
this.name = 'FeedlyError';
40+
this.code = code;
41+
this.userMessage = UserMessages[code] || UserMessages.UNKNOWN;
42+
}
43+
44+
getUserMessage() {
45+
return this.userMessage;
46+
}
47+
48+
getDebugInfo() {
49+
return `[${this.code}] ${this.message}`;
50+
}
51+
}
52+
53+
function classifyHttpError(status) {
54+
if (status === 401 || status === 403) return ErrorCode.AUTH_FAILED;
55+
if (status === 429) return ErrorCode.RATE_LIMITED;
56+
if (status >= 500) return ErrorCode.SERVER_ERROR;
57+
if (status >= 400) return ErrorCode.CLIENT_ERROR;
58+
return ErrorCode.UNKNOWN;
59+
}
860

961
// =============================================================================
1062
// Constants
@@ -55,15 +107,67 @@ function normalizeCount(count) {
55107
// Feedly API Functions
56108
// =============================================================================
57109

58-
// Token storage for communication between page and content script
59-
let cachedAccessToken = null;
110+
// Token storage with metadata for TTL and change detection
111+
let tokenCache = {
112+
token: null,
113+
cachedAt: 0,
114+
sourceHash: null
115+
};
116+
117+
/**
118+
* Generate a simple hash for change detection (djb2 algorithm).
119+
* @param {string} str - String to hash
120+
* @returns {number} Simple numeric hash
121+
*/
122+
function simpleHash(str) {
123+
if (!str) return 0;
124+
let hash = 0;
125+
for (let i = 0; i < str.length; i++) {
126+
const char = str.charCodeAt(i);
127+
hash = ((hash << 5) - hash) + char;
128+
hash = hash | 0; // Convert to 32-bit signed integer
129+
}
130+
return hash;
131+
}
132+
133+
/**
134+
* Check if cached token is still valid.
135+
* Validates TTL expiration and localStorage data integrity.
136+
* @returns {boolean} True if cache is valid
137+
*/
138+
function isTokenCacheValid() {
139+
if (!tokenCache.token) {
140+
return false;
141+
}
142+
143+
// Check TTL expiration
144+
if (Date.now() - tokenCache.cachedAt > TOKEN_CACHE_TTL_MS) {
145+
return false;
146+
}
147+
148+
// Validate against current localStorage
149+
try {
150+
const currentSessionData = localStorage.getItem("feedly.session");
151+
if (simpleHash(currentSessionData) !== tokenCache.sourceHash) {
152+
return false;
153+
}
154+
} catch (e) {
155+
return false;
156+
}
157+
158+
return true;
159+
}
60160

61161
/**
62162
* Clear the cached access token.
63163
* Called when authentication fails to allow re-fetching from localStorage.
64164
*/
65165
function clearAccessTokenCache() {
66-
cachedAccessToken = null;
166+
tokenCache = {
167+
token: null,
168+
cachedAt: 0,
169+
sourceHash: null
170+
};
67171
}
68172

69173
/**
@@ -77,18 +181,25 @@ function clearAccessTokenCache() {
77181
* @returns {Promise<string|null>} Access token or null if not available
78182
*/
79183
async function getAccessToken() {
80-
// Return cached token if available
81-
if (cachedAccessToken) {
82-
return cachedAccessToken;
184+
// Return cached token if still valid
185+
if (isTokenCacheValid()) {
186+
return tokenCache.token;
83187
}
84188

189+
// Clear expired/invalid cache
190+
clearAccessTokenCache();
191+
85192
try {
86193
const sessionData = localStorage.getItem("feedly.session");
87194
if (sessionData) {
88195
const session = JSON.parse(sessionData);
89196
if (session.feedlyToken) {
90-
cachedAccessToken = session.feedlyToken;
91-
return cachedAccessToken;
197+
tokenCache = {
198+
token: session.feedlyToken,
199+
cachedAt: Date.now(),
200+
sourceHash: simpleHash(sessionData)
201+
};
202+
return tokenCache.token;
92203
}
93204
}
94205
} catch (e) {
@@ -107,34 +218,53 @@ async function getAccessToken() {
107218
async function feedlyApiRequest(endpoint, options = {}) {
108219
const token = await getAccessToken();
109220
if (!token) {
110-
throw new Error("No Feedly access token available");
221+
throw new FeedlyError(ErrorCode.NO_TOKEN, 'No access token in localStorage');
111222
}
112223

113224
const url = `${FEEDLY_API_BASE}${endpoint}`;
114-
const response = await fetch(url, {
115-
...options,
116-
headers: {
117-
"Authorization": `Bearer ${token}`,
118-
"Content-Type": "application/json",
119-
...options.headers
120-
}
121-
});
225+
226+
let response;
227+
try {
228+
response = await fetch(url, {
229+
...options,
230+
headers: {
231+
"Authorization": `Bearer ${token}`,
232+
"Content-Type": "application/json",
233+
...options.headers
234+
}
235+
});
236+
} catch (networkError) {
237+
throw new FeedlyError(
238+
ErrorCode.NETWORK_ERROR,
239+
`Fetch failed: ${networkError.message}`
240+
);
241+
}
122242

123243
if (!response.ok) {
124244
// Clear token cache on authentication errors to allow retry with fresh token
125245
if (response.status === 401 || response.status === 403) {
126246
clearAccessTokenCache();
127247
}
128-
const errorText = await response.text().catch(() => "Unknown error");
129-
throw new Error(`Feedly API error ${response.status}: ${errorText}`);
248+
const errorBody = await response.text().catch(() => "");
249+
throw new FeedlyError(
250+
classifyHttpError(response.status),
251+
`API ${response.status}: ${errorBody.substring(0, 200)}`
252+
);
130253
}
131254

132255
// DELETE requests may return empty body
133256
if (response.status === 204 || response.headers.get("content-length") === "0") {
134257
return { success: true };
135258
}
136259

137-
return response.json();
260+
try {
261+
return await response.json();
262+
} catch (parseError) {
263+
throw new FeedlyError(
264+
ErrorCode.SERVER_ERROR,
265+
`Invalid JSON response: ${parseError.message}`
266+
);
267+
}
138268
}
139269

140270
/**
@@ -144,7 +274,7 @@ async function feedlyApiRequest(endpoint, options = {}) {
144274
async function getUserId() {
145275
const profile = await feedlyApiRequest("/v3/profile");
146276
if (!profile.id) {
147-
throw new Error("User ID not found in profile response");
277+
throw new FeedlyError(ErrorCode.SERVER_ERROR, "User ID not found in profile response");
148278
}
149279
return profile.id;
150280
}
@@ -242,11 +372,7 @@ async function unsaveEntriesViaAPI(userId, entryIds) {
242372
* @returns {Promise<Object>} Result object with ok, urls, and method
243373
*/
244374
async function handleOpenViaAPI(settings) {
245-
const token = await getAccessToken();
246-
if (!token) {
247-
throw new Error("No access token available. Please ensure you are logged into Feedly.");
248-
}
249-
375+
// Token check is handled by feedlyApiRequest() called from getUserId()
250376
const userId = await getUserId();
251377

252378
// Fetch entries: use pagination for "all" mode, single request for "count" mode
@@ -605,7 +731,7 @@ function findScrollContainer(startNode) {
605731
*/
606732
async function handleOpenViaDOM(settings) {
607733
if (!(await waitForReadLaterPage(2000))) {
608-
return { ok: false, error: "This tab is not a Feedly Read Later page." };
734+
throw new FeedlyError(ErrorCode.WRONG_PAGE, `Not on Read Later page: ${location.href}`);
609735
}
610736

611737
if (settings.mode === "all") {
@@ -639,28 +765,44 @@ async function handleOpen(settings) {
639765
result = await handleOpenViaAPI(settings);
640766
} catch (e) {
641767
apiError = e;
642-
console.warn("[Feedly Opener] API operation failed, falling back to DOM:", e.message);
768+
if (e instanceof FeedlyError) {
769+
console.warn("[Feedly Opener] API failed:", e.getDebugInfo());
770+
} else {
771+
console.warn("[Feedly Opener] API failed:", e.message);
772+
}
643773
}
644774

645775
// Fallback to DOM-based approach if API failed
646776
if (!result || !result.ok) {
647777
try {
648778
result = await handleOpenViaDOM(settings);
649-
if (apiError) {
650-
result.apiError = apiError.message;
651-
}
652779
} catch (domError) {
653-
console.error("[Feedly Opener] DOM operation also failed:", domError);
780+
if (domError instanceof FeedlyError) {
781+
console.error("[Feedly Opener] DOM failed:", domError.getDebugInfo());
782+
} else {
783+
console.error("[Feedly Opener] DOM failed:", domError.message);
784+
}
785+
786+
// Prioritize DOM WRONG_PAGE error as it's more actionable for users
787+
let userMessage;
788+
if (domError instanceof FeedlyError && domError.code === ErrorCode.WRONG_PAGE) {
789+
userMessage = domError.getUserMessage();
790+
} else if (apiError instanceof FeedlyError) {
791+
userMessage = apiError.getUserMessage();
792+
} else {
793+
userMessage = UserMessages.UNKNOWN;
794+
}
795+
654796
return {
655797
ok: false,
656-
error: `API error: ${apiError?.message || "unknown"}. DOM error: ${domError.message}`,
798+
error: userMessage,
657799
method: "failed"
658800
};
659801
}
660802
}
661803

662-
// Always reload after successful operation to reflect UI changes
663-
if (result.ok) {
804+
// Reload after successful operation if enabled (default: true)
805+
if (result.ok && settings.reload) {
664806
setTimeout(() => {
665807
location.reload();
666808
}, 1000);
@@ -706,6 +848,39 @@ function isRecentlyReadLater() {
706848
return location.origin === "https://feedly.com" && lastReadLaterUrl.length > 0;
707849
}
708850

851+
// =============================================================================
852+
// Message Security
853+
// =============================================================================
854+
855+
/**
856+
* Validate message sender is from our own extension.
857+
* NOTE: This is defense-in-depth. Messages via runtime.onMessage
858+
* should only come from our extension, but we validate explicitly
859+
* to ensure messages are from the expected source.
860+
* @param {Object} sender - Message sender object
861+
* @returns {boolean} True if sender is valid
862+
*/
863+
function validateSender(sender) {
864+
return sender && sender.id === api.runtime.id;
865+
}
866+
867+
/**
868+
* Validate and sanitize settings from message.
869+
* @param {Object} raw - Raw settings object from message
870+
* @returns {Object} Validated settings with safe defaults
871+
*/
872+
function validateSettings(raw) {
873+
const validModes = ['all', 'count'];
874+
const rawCount = Number(raw?.count);
875+
const parsedCount = Number.isFinite(rawCount) ? Math.floor(rawCount) : 10;
876+
const safeCount = parsedCount > 0 ? parsedCount : 10;
877+
return {
878+
mode: validModes.includes(raw?.mode) ? raw.mode : 'all',
879+
count: Math.max(1, Math.min(999, safeCount)),
880+
reload: typeof raw?.reload === 'boolean' ? raw.reload : true
881+
};
882+
}
883+
709884
// =============================================================================
710885
// Message Listener
711886
// =============================================================================
@@ -717,7 +892,23 @@ if (!window.__feedlyReadLaterOpenerListenerAdded) {
717892
return false;
718893
}
719894

720-
handleOpen(message.settings || {}).then(sendResponse);
895+
// Validate sender is from our own extension
896+
if (!validateSender(sender)) {
897+
sendResponse({ ok: false, error: "Invalid message sender" });
898+
return true;
899+
}
900+
901+
// Validate and sanitize settings
902+
const settings = validateSettings(message.settings);
903+
904+
handleOpen(settings)
905+
.then(sendResponse)
906+
// Defensive catch for unexpected errors (normal flow returns result object)
907+
.catch((e) => {
908+
console.error("[Feedly Opener]", e);
909+
const userMessage = e instanceof FeedlyError ? e.getUserMessage() : UserMessages.UNKNOWN;
910+
sendResponse({ ok: false, error: userMessage });
911+
});
721912
return true;
722913
});
723914
}

popup.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ const usesPromises = typeof browser !== "undefined";
44
const SETTINGS_KEY = "feedlyOpenerSettings";
55
const DEFAULT_SETTINGS = {
66
mode: "all",
7-
count: 10
7+
count: 10,
8+
reload: true
89
};
910

1011
const storageArea =
@@ -190,7 +191,8 @@ function readSettingsFromForm() {
190191

191192
return {
192193
mode,
193-
count
194+
count,
195+
reload: DEFAULT_SETTINGS.reload
194196
};
195197
}
196198

0 commit comments

Comments
 (0)