Skip to content
Merged
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
1 change: 1 addition & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file removed .yarn/cache/fsevents-patch-19706e7e35-10.zip
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { AnalyticsActivity } from '../Analytics/AnalyticsActivity'
import { StatisticMeasureName } from '../Statistics/StatisticMeasureName'
import { Period } from '../Time/Period'

import { safeHtml } from '@standardnotes/common'

const countActiveUsers = (measureName: string, data: any): { yesterday: number; last30Days: number } => {
const totalActiveUsersLast30DaysIncludingToday = data.statisticsOverTime.find(
(a: { name: string; period: number }) => a.name === measureName && a.period === 27,
Expand Down Expand Up @@ -567,7 +569,7 @@ export const html = (data: any, timer: TimerInterface) => {
const totalActivePlusUsers = countActiveUsers(StatisticMeasureName.NAMES.ActivePlusUsers, data)
const totalActiveProUsers = countActiveUsers(StatisticMeasureName.NAMES.ActiveProUsers, data)

return ` <div>
return safeHtml` <div>
<p>Hello,</p>
<p>
<strong>Here are some statistics from yesterday:</strong>
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const html = (userEmail: string, offlineSubscriptionDashboardUrl: string) => `<div class="sn-component">
import { safeHtml } from '@standardnotes/common'

export const html = (userEmail: string, offlineSubscriptionDashboardUrl: string) => safeHtml`<div class="sn-component">
<div class="sk-panel static">
<div class="sk-panel-content">
<div class="sk-panel-section">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const html = (inviterIdentifier: string, inviteUuid: string) => `<p>Hello,</p>
import { safeHtml } from '@standardnotes/common'

export const html = (inviterIdentifier: string, inviteUuid: string) => safeHtml`<p>Hello,</p>
<p>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.</p>
<p>
<a href='https://app.standardnotes.com/?accept-subscription-invite=${inviteUuid}'>Accept Invite</a>
Expand Down
4 changes: 3 additions & 1 deletion packages/auth/src/Domain/Email/user-email-changed.html.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const html = (newEmail: string) => `
import { safeHtml } from '@standardnotes/common'

export const html = (newEmail: string) => safeHtml`
<p>Hello,</p>

<p>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:</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const html = () => `
import { safeHtml } from '@standardnotes/common'

export const html = () => safeHtml`
<p>Hello,</p>

<p>You've been invited to join a shared vault. This shared workspace will help you collaborate and securely manage notes and files.</p>
Expand Down
4 changes: 3 additions & 1 deletion packages/auth/src/Domain/Email/user-signed-in.html.ts
Original file line number Diff line number Diff line change
@@ -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`
<div>
<p>Hello,</p>
<p>We've detected a new sign-in to your account ${email}</p>
Expand Down
33 changes: 25 additions & 8 deletions packages/auth/src/Domain/Setting/SettingCrypter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
13 changes: 13 additions & 0 deletions packages/auth/src/Domain/Setting/SettingCrypter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class ActivatePremiumFeatures implements UseCaseInterface<string> {
) {}

async execute(dto: ActivatePremiumFeaturesDTO): Promise<Result<string>> {
const usernameOrError = Username.create(dto.username)
const usernameOrError = Username.create(dto.username, { skipValidation: true })
if (usernameOrError.isFailed()) {
return Result.fail(usernameOrError.getError())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class GetUserKeyParams implements UseCaseInterface {
async execute(dto: GetUserKeyParamsDTO): Promise<GetUserKeyParamsResponse> {
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())
}
Expand Down
3 changes: 2 additions & 1 deletion packages/auth/src/Domain/UseCase/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/src/Domain/UseCase/VerifyMFA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class VerifyMFA implements UseCaseInterface {

async execute(dto: VerifyMFADTO): Promise<VerifyMFAResponse> {
try {
const usernameOrError = Username.create(dto.email)
const usernameOrError = Username.create(dto.email, { skipValidation: true })
if (usernameOrError.isFailed()) {
return {
success: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class LockMiddleware extends BaseMiddleware {
async handler(request: Request, response: Response, next: NextFunction): Promise<void> {
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: {
Expand Down
16 changes: 16 additions & 0 deletions packages/common/src/Domain/Html/SafeHtml.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { safeHtml } from './SafeHtml'

describe('html', () => {
test('Should escape html from user input', () => {
const basicStringInput = '<h1>User</h1>'
const numberValue = 10
expect(safeHtml`<p>Hello world, ${basicStringInput} ${numberValue}</p>`).toBe(
'<p>Hello world, &lt;h1&gt;User&lt;/h1&gt; 10</p>',
)
})

test('Should join arrays and escape', () => {
const arrayOfStrings = ['<h1>User</h1>', '<p>Test</p>']
expect(safeHtml`<p>${arrayOfStrings}</p>`).toBe('<p>&lt;h1&gt;User&lt;/h1&gt;&lt;p&gt;Test&lt;/p&gt;</p>')
})
})
32 changes: 32 additions & 0 deletions packages/common/src/Domain/Html/SafeHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
function escapeHTML(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/`/g, '&#96;')
}

/**
* Template handler that does basic HTML escaping for substitutions
*/
export function safeHtml(literals: TemplateStringsArray, ...substitutions: Array<string | number | string[]>) {
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
}
1 change: 1 addition & 0 deletions packages/common/src/Domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './Subscription/SubscriptionName'
export * from './Type/Either'
export * from './Type/Only'
export * from './User/UserRequestType'
export * from './Html/SafeHtml'
Loading