Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions src/lib/auth.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import config from "./config";
import { maskCredential, unmaskCredential } from "../security/security";

/**
* @typedef {object} User
Expand Down Expand Up @@ -43,6 +44,52 @@ const loginEvents = {
},
};

/**
* Clones the user object structure and masks sensitive string properties
* to protect them in memory.
*/
function secureUserObject(user) {
if (!user) return null;

const secured = { ...user };

// Array with String Properties of Object
const textProperties = [
"name", "role", "email", "github", "website",
"avatar_url", "pro_purchased_at", "created_at", "updated_at"
];

// Apply the Mask
textProperties.forEach(prop => {
if (typeof secured[prop] === "string") {
secured[prop] = maskCredential(secured[prop]);
}
});

return secured;
}

/** Function to unmask the user object properties
* so the interface can read them safely without crashing.
*/
export function getDecryptedUser(user) {
if (!user) return null;
const decrypted = { ...user };

const textProperties = [
"name", "role", "email", "github", "website",
"avatar_url", "pro_purchased_at", "created_at", "updated_at"
];

textProperties.forEach(prop => {
if (Array.isArray(decrypted[prop])) {
decrypted[prop] = unmaskCredential(decrypted[prop]);
}
});

return decrypted;
}

class AuthService {
#loginCallbacks = new Set();
#loginTimeout = null;
Expand Down Expand Up @@ -105,17 +152,18 @@ class AuthService {
* @returns {Promise<User>}
*/
async getLoggedInUser(forceFetch = false) {
if (loggedInUser && !forceFetch) return loggedInUser;
if (loggedInUser && !forceFetch) return getDecryptedUser(loggedInUser);

try {
const res = await fetch(`${config.API_BASE}/login`);

if (res.ok) {
loggedInUser = await res.json();
localStorage.setItem(CACHE_USER_KEY, JSON.stringify(loggedInUser));
clearTimeout(cacheTimeout);
cacheTimeout = setTimeout(() => (loggedInUser = null), 600_000);
return loggedInUser;
const rawuser = await res.json();
loggedInUser = secureUserObject(rawuser);
localStorage.setItem(CACHE_USER_KEY, JSON.stringify(loggedInUser));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cached user remains masked After a successful login saves masked fields to the cache, a later /login request that fails, such as while offline, returns that cache without decrypting it. Callers receive arrays instead of strings, so the sidebar can display numbers for the email or throw while generating an avatar from the name.

clearTimeout(cacheTimeout);
cacheTimeout = setTimeout(() => (loggedInUser = null), 600_000);
return getDecryptedUser(loggedInUser);
}

if (res.status === 401) {
Expand Down
30 changes: 30 additions & 0 deletions src/security/security.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Copyright (C) dev12124 (dev brazilian, João Guilherme da Silva Freitas Lima),
* License: MIT license.
*/

/**
* Mask key used to obfuscate sensitive Acode credentials.
* In JavaScript, plain text variables (e.g., let key = "secret password")
* reside in the RAM unprotected, making them vulnerable to access or modification
* by malicious installed plugins.
*/
const MASK_KEY = 0x5A;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Fixed key exposes cached data A malicious plugin runs as a script in the application page and can read localStorage. It can reverse each stored number using the fixed 0x5A key in the client code, recovering the email and other masked fields. The masking therefore does not provide the intended protection against malicious plugins.

How this was verified: Plugin scripts share the page that writes the masked cache, and the stored numbers are reversed with the fixed key present in the client code.

Knowledge Base Used: Plugins and platform services


// Applies a XOR mask to secure sensitive strings
export function maskCredential(secretString) {
if (!secretString) return [];

// Transforms the string into a masked array of bytes (numbers)
return Array.from(secretString).map(char => char.charCodeAt(0) ^ MASK_KEY);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Masking corrupts Unicode characters When a profile field contains an emoji or another supplementary Unicode character, Array.from keeps it as one element but charCodeAt(0) records only half of it. Unmasking cannot restore the original character, so the displayed value is corrupted and the corrupted form is saved in the cache.

Suggested change
return Array.from(secretString).map(char => char.charCodeAt(0) ^ MASK_KEY);
return secretString.split("").map(char => char.charCodeAt(0) ^ MASK_KEY);

}

// Removes the XOR mask to restore the original string
export function unmaskCredential(maskedArray) {
if (!Array.isArray(maskedArray)) return " ";

// Removes the mask
return maskedArray
.map(byte => String.fromCharCode(byte ^ MASK_KEY))
.join("");
}
Loading