Hello,
Here are some statistics from yesterday:
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 60467098c..a9960161d 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -4,7 +4,7 @@
"engines": {
"node": ">=18.0.0 <21.0.0"
},
- "description": "Auth Server",
+ "description": "Auth Server for SN",
"main": "dist/src/index.js",
"typings": "dist/src/index.d.ts",
"author": "Karol Sójko ",
diff --git a/packages/auth/src/Domain/Email/offline-subscription-token-created.html.ts b/packages/auth/src/Domain/Email/offline-subscription-token-created.html.ts
index 6e7497ba4..99a250461 100644
--- a/packages/auth/src/Domain/Email/offline-subscription-token-created.html.ts
+++ b/packages/auth/src/Domain/Email/offline-subscription-token-created.html.ts
@@ -1,4 +1,6 @@
-export const html = (userEmail: string, offlineSubscriptionDashboardUrl: string) => `
+import { safeHtml } from '@standardnotes/common'
+
+export const html = (userEmail: string, offlineSubscriptionDashboardUrl: string) => safeHtml`
diff --git a/packages/auth/src/Domain/Email/shared-subscription-invitation-created.html.ts b/packages/auth/src/Domain/Email/shared-subscription-invitation-created.html.ts
index 2ed1bacb8..422a0a5e8 100644
--- a/packages/auth/src/Domain/Email/shared-subscription-invitation-created.html.ts
+++ b/packages/auth/src/Domain/Email/shared-subscription-invitation-created.html.ts
@@ -1,4 +1,6 @@
-export const html = (inviterIdentifier: string, inviteUuid: string) => `
Hello,
+import { safeHtml } from '@standardnotes/common'
+
+export const html = (inviterIdentifier: string, inviteUuid: string) => safeHtml`
Hello,
You've been invited to join a Standard Notes premium subscription at no cost. ${inviterIdentifier} has invited you to share the benefits of their subscription plan.
Accept Invite
diff --git a/packages/auth/src/Domain/Email/user-email-changed.html.ts b/packages/auth/src/Domain/Email/user-email-changed.html.ts
index c8f7d2d35..c275bc780 100644
--- a/packages/auth/src/Domain/Email/user-email-changed.html.ts
+++ b/packages/auth/src/Domain/Email/user-email-changed.html.ts
@@ -1,4 +1,6 @@
-export const html = (newEmail: string) => `
+import { safeHtml } from '@standardnotes/common'
+
+export const html = (newEmail: string) => safeHtml`
Hello,
We are writing to inform you that your request to update your email address has been successfully processed. The email address associated with your Standard Notes account has now been changed to the following:
diff --git a/packages/auth/src/Domain/Email/user-invited-to-shared-vault.html.ts b/packages/auth/src/Domain/Email/user-invited-to-shared-vault.html.ts
index bcd1be005..8bca278bc 100644
--- a/packages/auth/src/Domain/Email/user-invited-to-shared-vault.html.ts
+++ b/packages/auth/src/Domain/Email/user-invited-to-shared-vault.html.ts
@@ -1,4 +1,6 @@
-export const html = () => `
+import { safeHtml } from '@standardnotes/common'
+
+export const html = () => safeHtml`
Hello,
You've been invited to join a shared vault. This shared workspace will help you collaborate and securely manage notes and files.
diff --git a/packages/auth/src/Domain/Email/user-signed-in.html.ts b/packages/auth/src/Domain/Email/user-signed-in.html.ts
index c72b9210e..a2be181d7 100644
--- a/packages/auth/src/Domain/Email/user-signed-in.html.ts
+++ b/packages/auth/src/Domain/Email/user-signed-in.html.ts
@@ -1,4 +1,6 @@
-export const html = (email: string, device: string, browser: string, timeAndDate: string) => `
+import { safeHtml } from '@standardnotes/common'
+
+export const html = (email: string, device: string, browser: string, timeAndDate: string) => safeHtml`
Hello,
We've detected a new sign-in to your account ${email}
diff --git a/packages/auth/src/Domain/Setting/SettingCrypter.spec.ts b/packages/auth/src/Domain/Setting/SettingCrypter.spec.ts
index 7fb7d4451..1abed0a4f 100644
--- a/packages/auth/src/Domain/Setting/SettingCrypter.spec.ts
+++ b/packages/auth/src/Domain/Setting/SettingCrypter.spec.ts
@@ -13,6 +13,8 @@ describe('SettingCrypter', () => {
let userRepository: UserRepositoryInterface
let crypter: CrypterInterface
let user: User
+ const encryptedValue =
+ '{"version":1,"encrypted":{"iv":"foobar","tag":"foobar","aad":"","ciphertext":"foobar","encoding":"utf-8"}}'
const createDecrypter = () => new SettingCrypter(userRepository, crypter)
@@ -32,14 +34,14 @@ describe('SettingCrypter', () => {
it('should encrypt a string value', async () => {
const string = 'decrypted'
- crypter.encryptForUser = jest.fn().mockReturnValue('encrypted')
+ crypter.encryptForUser = jest.fn().mockReturnValue(encryptedValue)
const encrypted = await createDecrypter().encryptValue(
string,
Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
)
- expect(encrypted).toEqual('encrypted')
+ expect(encrypted).toEqual(encryptedValue)
})
it('should return null when trying to encrypt a null value', async () => {
@@ -67,7 +69,7 @@ describe('SettingCrypter', () => {
it('should decrypt an encrypted value of a setting', async () => {
const setting = Setting.create({
name: SettingName.NAMES.ListedAuthorSecrets,
- value: 'encrypted',
+ value: encryptedValue,
serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false,
@@ -107,10 +109,25 @@ describe('SettingCrypter', () => {
)
})
+ it('should return unencrypted value if the setting has unencrypted value but the encryption version indicates otherwise', async () => {
+ const setting = Setting.create({
+ name: SettingName.NAMES.ListedAuthorSecrets,
+ value: 'test',
+ serverEncryptionVersion: EncryptionVersion.Default,
+ userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+ sensitive: false,
+ timestamps: Timestamps.create(123, 123).getValue(),
+ }).getValue()
+
+ expect(await createDecrypter().decryptSettingValue(setting, '00000000-0000-0000-0000-000000000000')).toEqual(
+ 'test',
+ )
+ })
+
it('should throw if the user could not be found', async () => {
const setting = Setting.create({
name: SettingName.NAMES.ListedAuthorSecrets,
- value: 'encrypted',
+ value: encryptedValue,
serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false,
@@ -131,7 +148,7 @@ describe('SettingCrypter', () => {
it('should throw if the user uuid is invalid', async () => {
const setting = Setting.create({
name: SettingName.NAMES.ListedAuthorSecrets,
- value: 'encrypted',
+ value: encryptedValue,
serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false,
@@ -153,7 +170,7 @@ describe('SettingCrypter', () => {
it('should decrypt an encrypted value of a setting', async () => {
const setting = SubscriptionSetting.create({
name: SettingName.NAMES.ExtensionKey,
- value: 'encrypted',
+ value: encryptedValue,
sensitive: true,
serverEncryptionVersion: EncryptionVersion.Default,
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
@@ -198,7 +215,7 @@ describe('SettingCrypter', () => {
it('should throw if the user could not be found', async () => {
const setting = SubscriptionSetting.create({
name: SettingName.NAMES.ExtensionKey,
- value: 'encrypted',
+ value: encryptedValue,
sensitive: true,
serverEncryptionVersion: EncryptionVersion.Default,
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
@@ -219,7 +236,7 @@ describe('SettingCrypter', () => {
it('should throw if the user uuid is invalid', async () => {
const setting = SubscriptionSetting.create({
name: SettingName.NAMES.ExtensionKey,
- value: 'encrypted',
+ value: encryptedValue,
sensitive: true,
serverEncryptionVersion: EncryptionVersion.Default,
userSubscriptionUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
diff --git a/packages/auth/src/Domain/Setting/SettingCrypter.ts b/packages/auth/src/Domain/Setting/SettingCrypter.ts
index 66ed243b2..02a5385c0 100644
--- a/packages/auth/src/Domain/Setting/SettingCrypter.ts
+++ b/packages/auth/src/Domain/Setting/SettingCrypter.ts
@@ -52,9 +52,22 @@ export class SettingCrypter implements SettingCrypterInterface {
throw new Error(`Could not find user with uuid: ${userUuid.value}`)
}
+ if (!this.isValidJSONSubjectForDecryption(value)) {
+ return value
+ }
+
return this.crypter.decryptForUser(value, user)
}
return value
}
+
+ private isValidJSONSubjectForDecryption(value: string): boolean {
+ try {
+ JSON.parse(value)
+ return true
+ } catch (error) {
+ return false
+ }
+ }
}
diff --git a/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts b/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts
index 4efdd54b6..556f51a71 100644
--- a/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts
+++ b/packages/auth/src/Domain/UseCase/ActivatePremiumFeatures/ActivatePremiumFeatures.ts
@@ -19,7 +19,7 @@ export class ActivatePremiumFeatures implements UseCaseInterface
{
) {}
async execute(dto: ActivatePremiumFeaturesDTO): Promise> {
- const usernameOrError = Username.create(dto.username)
+ const usernameOrError = Username.create(dto.username, { skipValidation: true })
if (usernameOrError.isFailed()) {
return Result.fail(usernameOrError.getError())
}
diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.ts
index 17818f6bf..81f708857 100644
--- a/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.ts
+++ b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.ts
@@ -22,7 +22,7 @@ export class GetUserKeyParams implements UseCaseInterface {
async execute(dto: GetUserKeyParamsDTO): Promise {
let user: User | null = null
if (dto.email !== undefined) {
- const usernameOrError = Username.create(dto.email)
+ const usernameOrError = Username.create(dto.email, { skipValidation: true })
if (usernameOrError.isFailed()) {
throw Error(usernameOrError.getError())
}
diff --git a/packages/auth/src/Domain/UseCase/SignIn.ts b/packages/auth/src/Domain/UseCase/SignIn.ts
index 80c3f19e2..1d7ab1fa5 100644
--- a/packages/auth/src/Domain/UseCase/SignIn.ts
+++ b/packages/auth/src/Domain/UseCase/SignIn.ts
@@ -62,7 +62,8 @@ export class SignIn implements UseCaseInterface {
}
const apiVersion = apiVersionOrError.getValue()
- const usernameOrError = Username.create(dto.email)
+ /** Skip validation which was newly added in 2025, to allow existing users to continue to sign in */
+ const usernameOrError = Username.create(dto.email, { skipValidation: true })
if (usernameOrError.isFailed()) {
return {
success: false,
diff --git a/packages/auth/src/Domain/UseCase/VerifyMFA.ts b/packages/auth/src/Domain/UseCase/VerifyMFA.ts
index 3afb076ba..2ef4cc1f8 100644
--- a/packages/auth/src/Domain/UseCase/VerifyMFA.ts
+++ b/packages/auth/src/Domain/UseCase/VerifyMFA.ts
@@ -32,7 +32,7 @@ export class VerifyMFA implements UseCaseInterface {
async execute(dto: VerifyMFADTO): Promise {
try {
- const usernameOrError = Username.create(dto.email)
+ const usernameOrError = Username.create(dto.email, { skipValidation: true })
if (usernameOrError.isFailed()) {
return {
success: false,
diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts
index f78a7e76b..7834e3f9f 100644
--- a/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts
+++ b/packages/auth/src/Infra/InversifyExpressUtils/Base/BaseUsersController.ts
@@ -135,7 +135,7 @@ export class BaseUsersController extends BaseHttpController {
400,
)
}
- const usernameOrError = Username.create(locals.user.email)
+ const usernameOrError = Username.create(locals.user.email, { skipValidation: true })
if (usernameOrError.isFailed()) {
return this.json(
{
diff --git a/packages/auth/src/Infra/InversifyExpressUtils/Middleware/LockMiddleware.ts b/packages/auth/src/Infra/InversifyExpressUtils/Middleware/LockMiddleware.ts
index 6bf265749..2474c5e04 100644
--- a/packages/auth/src/Infra/InversifyExpressUtils/Middleware/LockMiddleware.ts
+++ b/packages/auth/src/Infra/InversifyExpressUtils/Middleware/LockMiddleware.ts
@@ -19,7 +19,7 @@ export class LockMiddleware extends BaseMiddleware {
async handler(request: Request, response: Response, next: NextFunction): Promise {
try {
let identifier = request.body.email ?? request.body.username
- const usernameOrError = Username.create(identifier)
+ const usernameOrError = Username.create(identifier, { skipValidation: true })
if (usernameOrError.isFailed()) {
response.status(400).send({
error: {
diff --git a/packages/common/src/Domain/Html/SafeHtml.spec.ts b/packages/common/src/Domain/Html/SafeHtml.spec.ts
new file mode 100644
index 000000000..5d65a56aa
--- /dev/null
+++ b/packages/common/src/Domain/Html/SafeHtml.spec.ts
@@ -0,0 +1,16 @@
+import { safeHtml } from './SafeHtml'
+
+describe('html', () => {
+ test('Should escape html from user input', () => {
+ const basicStringInput = 'User
'
+ const numberValue = 10
+ expect(safeHtml`Hello world, ${basicStringInput} ${numberValue}
`).toBe(
+ 'Hello world, <h1>User</h1> 10
',
+ )
+ })
+
+ test('Should join arrays and escape', () => {
+ const arrayOfStrings = ['User
', 'Test
']
+ expect(safeHtml`${arrayOfStrings}
`).toBe('<h1>User</h1><p>Test</p>
')
+ })
+})
diff --git a/packages/common/src/Domain/Html/SafeHtml.ts b/packages/common/src/Domain/Html/SafeHtml.ts
new file mode 100644
index 000000000..e49bd13c8
--- /dev/null
+++ b/packages/common/src/Domain/Html/SafeHtml.ts
@@ -0,0 +1,32 @@
+function escapeHTML(str: string) {
+ return str
+ .replace(/&/g, '&')
+ .replace(/>/g, '>')
+ .replace(/) {
+ const raw = literals.raw
+
+ let result = raw[0]
+
+ for (let index = 1; index < raw.length; index++) {
+ const literal = raw[index]
+ let substitution = substitutions[index - 1]
+ if (Array.isArray(substitution)) {
+ substitution = substitution.join('')
+ } else if (typeof substitution === 'number') {
+ substitution = substitution.toString()
+ }
+ substitution = escapeHTML(substitution)
+ result += substitution + literal
+ }
+
+ return result
+}
diff --git a/packages/common/src/Domain/index.ts b/packages/common/src/Domain/index.ts
index d788e6484..c6fe229d0 100644
--- a/packages/common/src/Domain/index.ts
+++ b/packages/common/src/Domain/index.ts
@@ -18,3 +18,4 @@ export * from './Subscription/SubscriptionName'
export * from './Type/Either'
export * from './Type/Only'
export * from './User/UserRequestType'
+export * from './Html/SafeHtml'
diff --git a/packages/domain-core/src/Domain/Common/Username.spec.ts b/packages/domain-core/src/Domain/Common/Username.spec.ts
index bfe3fdeeb..936fa1559 100644
--- a/packages/domain-core/src/Domain/Common/Username.spec.ts
+++ b/packages/domain-core/src/Domain/Common/Username.spec.ts
@@ -31,4 +31,178 @@ describe('Username', () => {
expect(value.isPotentiallyAPrivateUsernameAccount()).toBeFalsy()
})
+
+ describe('username validation', () => {
+ describe('valid usernames', () => {
+ const validUsernames = [
+ 'johndoe',
+ 'john_doe',
+ 'john.doe',
+ 'john-doe',
+ 'john@doe',
+ 'john123',
+ 'j0hn.d0e',
+ 'user+name',
+ 'username_with_single_underscore',
+ // Maximum length
+ 'a'.repeat(100),
+ // Minimum length
+ 'abc',
+ // Email variants
+ 'user@example.com',
+ 'user.name@example.com',
+ 'user+test@example.com',
+ 'user-name@example.com',
+ 'user_name@example.com',
+ 'user123@example.com',
+ 'u@example.com',
+ 'user@sub.example.com',
+ 'user@example-site.com',
+ 'user@example.co.uk',
+ 'user+test+extra@example.com',
+ 'user-name-extra@example.com',
+ 'user.name.extra@example.com',
+ 'user.name+test-extra@example.com',
+ 'user-name.test+extra@example.com',
+ ]
+
+ test.each(validUsernames)('should accept valid username: %s', (username) => {
+ const result = Username.create(username)
+ expect(result.isFailed()).toBeFalsy()
+ expect(result.getValue().value).toBe(username.toLowerCase())
+ })
+ })
+
+ describe('invalid usernames', () => {
+ const invalidUsernames = [
+ // Length violations
+ ['ab', 'Username must be at least 3 characters long'],
+ ['a'.repeat(101), 'Username cannot be longer than 100 characters'],
+
+ // Empty or whitespace
+ ['', 'Username cannot be empty'],
+ [' ', 'Username cannot be empty'],
+ [' ', 'Username cannot be empty'],
+
+ // Whitespace in username
+ ['user name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user\tname', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user\nname', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+
+ // Starting/ending with special characters
+ [
+ '_username',
+ 'Username cannot start or end with special characters, and cannot have consecutive special characters',
+ ],
+ [
+ 'username_',
+ 'Username cannot start or end with special characters, and cannot have consecutive special characters',
+ ],
+ [
+ '.username',
+ 'Username cannot start or end with special characters, and cannot have consecutive special characters',
+ ],
+ [
+ 'username.',
+ 'Username cannot start or end with special characters, and cannot have consecutive special characters',
+ ],
+
+ // Consecutive special characters
+ [
+ 'user__name',
+ 'Username cannot start or end with special characters, and cannot have consecutive special characters',
+ ],
+ [
+ 'user..name',
+ 'Username cannot start or end with special characters, and cannot have consecutive special characters',
+ ],
+ [
+ 'user.-name',
+ 'Username cannot start or end with special characters, and cannot have consecutive special characters',
+ ],
+
+ // Invalid special characters
+ ['user{name}', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user#name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user$name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user&name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user*name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user!name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user/name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user\\name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user"name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ["user'name", 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user:name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+ ['user=name', 'Username can only contain letters, numbers, and the following special characters: . _ - @ +'],
+
+ // HTML-like patterns
+ ['