Skip to content

fix: mask sensitive fields in notification API responses#3559

Open
egeoztass wants to merge 5 commits into
bluewave-labs:developfrom
egeoztass:fix/mask-twilio-sensitive-fields
Open

fix: mask sensitive fields in notification API responses#3559
egeoztass wants to merge 5 commits into
bluewave-labs:developfrom
egeoztass:fix/mask-twilio-sensitive-fields

Conversation

@egeoztass
Copy link
Copy Markdown
Contributor

Summary

Follow-up to #3534 as suggested by @ajhollid — masks accessToken and accountSid in all notification API responses to prevent plaintext exposure of credentials.

Changes

Single file change: server/src/controllers/notificationController.ts

  • Mask in responses: All GET and mutating endpoints now return masked sensitive fields (e.g., ACxxxxxxxxACxx****)
  • Strip on edit: When updating a notification, masked values are detected and stripped so they don't overwrite real credentials in the database
  • No impact on notification sending: Internal providers read directly from the database, so masking API responses doesn't affect functionality

Fields masked

Field Used by Example
accessToken Twilio, Telegram, Pushover, Matrix token-abctoke****
accountSid Twilio ACxxxxxxxxACxx****

Endpoints affected

  • POST /notifications (create response)
  • GET /notifications/team (list)
  • GET /notifications/:id (detail)
  • PATCH /notifications/:id (edit response)

Test plan

  • Create a Twilio notification, verify response has masked accountSid and accessToken
  • List notifications, verify sensitive fields are masked
  • Edit a notification without changing credentials — verify existing credentials are preserved
  • Edit a notification with new credentials — verify new values are saved
  • Test notification sending still works (masking only affects API responses, not internal provider calls)

🤖 Generated with Claude Code

Masks accessToken and accountSid in all notification API responses
(create, get by ID, get by team, edit) to prevent plaintext exposure
of credentials.

- GET responses show masked values (e.g., "ACxx****")
- Edit endpoint strips masked values to avoid overwriting real
  credentials when the user doesn't change them
- Internal notification sending is unaffected (uses unmasked DB values)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@ajhollid ajhollid left a comment

Choose a reason for hiding this comment

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

Thanks for the quick turnaround here! I think there is a more optimal solution we can implement here for better UX, please see how handling of API keys works on the settings page. I think that's a more robust solution here. Take a look and see what you think!

Comment on lines +19 to +24
const MASK_SUFFIX = "****";

const maskValue = (value: string): string => {
if (value.length <= 4) return MASK_SUFFIX;
return value.slice(0, 4) + MASK_SUFFIX;
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This works, but it's a pretty fragile solution and an undocumented contract 🤔

Why don't we return a sentinel value like how setting the Pagespeed API key works? I think that's a more robust solution. It requires a bit of fiddling around on the back and front end but makes for better UX

Refactored to match the existing settings page pattern:

Backend:
- Removes accessToken and accountSid from API responses entirely
- Adds boolean sentinels (accessTokenSet, accountSidSet) to indicate
  if credentials are stored

Frontend:
- Shows "Token is set" + Reset button when sentinel is true in edit mode
- Shows input field when creating or after clicking Reset
- Omits unchanged sensitive fields from edit submissions
- Updated Zod schemas to make sensitive fields optional for edit mode

Affects: Twilio, Telegram, Pushover, and Matrix notification channels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@egeoztass
Copy link
Copy Markdown
Contributor Author

Refactored to use the sentinel value pattern from the settings page:

Backend:

  • accessToken and accountSid are deleted from all API responses
  • Boolean sentinels accessTokenSet and accountSidSet are added to indicate if credentials are stored

Frontend:

  • Edit mode: shows "Token is set" + Reset button when sentinel is true
  • After clicking Reset (or when creating): shows the input field
  • Unchanged sensitive fields are omitted from edit submissions
  • Zod schemas updated to make sensitive fields optional for edit mode

Affects all channels using accessToken: Twilio, Telegram, Pushover, and Matrix.

995/995 tests passing.

@egeoztass
Copy link
Copy Markdown
Contributor Author

Screenshot

Edit mode showing sentinel UI for sensitive fields — Account SID and Auth Token are hidden with a "Reset" option:

image

Copy link
Copy Markdown
Collaborator

@ajhollid ajhollid left a comment

Choose a reason for hiding this comment

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

Saving after setting token and sid doesn't work at the moment. Those fields should be stripped if they are already set

Image

When editing a notification with credentials already set, the frontend
omits these fields. Backend validation was rejecting the request because
they were required. Now optional on Telegram, Pushover, Twilio, and
Matrix schemas to match the sentinel pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@egeoztass
Copy link
Copy Markdown
Contributor Author

Fixed — the backend Zod validation was still requiring accessToken and accountSid as mandatory fields, so editing without changing them caused a validation error. Made them optional on all affected schemas (Telegram, Pushover, Twilio, Matrix).

995/995 tests passing.

Copy link
Copy Markdown
Collaborator

@ajhollid ajhollid left a comment

Choose a reason for hiding this comment

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

Image

Errors on creating without required fields 🤔 Can you take a look at the validation schema?

Create validation requires accessToken/accountSid (new notifications
must provide credentials). Edit validation makes them optional (existing
credentials are preserved when not provided).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@egeoztass
Copy link
Copy Markdown
Contributor Author

Fixed — split validation into two schemas:

  • createNotificationBodyValidation — requires accessToken/accountSid (new notifications must provide credentials)
  • editNotificationBodyValidation — makes them optional (existing credentials preserved when not provided)

The edit endpoint now uses the edit schema. Create and test endpoints still use the strict schema.

995/995 tests passing.

Copy link
Copy Markdown
Collaborator

@ajhollid ajhollid left a comment

Choose a reason for hiding this comment

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

This is a decent approach, but we should derive the edit notification validation from the create notification validation so we don't duplicate the schema.

Why don't we declare the schemas explictly:

  const pushoverSchema = z.object({                                                                                                                                                                                          
        notificationName: z.string().min(1, "Notification name is required"),
        type: z.literal("pushover"),
        address: z.string().min(1, "User key is required"),
        accessToken: z.string().min(1, "App token is required"),                                                                                                                                                             
  });
                                                                                                                                                                                                                             
  const twilioSchema = z.object({
        notificationName: z.string().min(1, "Notification name is required"),
        type: z.literal("twilio"),                                                                                                                                                                                           
        accountSid: z.string().min(1, "Account SID is required"),
        accessToken: z.string().min(1, "Auth token is required"),                                                                                                                                                            
        phone: z.string().min(1, "Recipient phone number is required"),                                                                                                                                                      
        twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
  }); 

And then use unions to create the validations:

  export const createNotificationBodyValidation = z.discriminatedUnion("type", [
        ...
        pushoverSchema,
        twilioSchema,
  ]);

  export const editNotificationBodyValidation = z.discriminatedUnion("type", [
        ...                                                                                                                                                              
        pushoverSchema.partial({ accessToken: true }),
        twilioSchema.partial({ accessToken: true, accountSid: true }),                                                                                                                                                       
  ]);  

This saves us a lot of duplication.

Other than that, we should do something server side about stripping values when editing notifications, we don't want to rely on the client to submit a well formatted request.

Thanks again for changes!

Comment on lines +20 to +30
const sanitizeNotification = (notification: Notification) => {
const sanitized: Record<string, unknown> = { ...notification };
const sentinels: Record<string, boolean> = {};

for (const field of SENSITIVE_FIELDS) {
sentinels[`${field}Set`] = typeof sanitized[field] !== "undefined" && sanitized[field] !== null && sanitized[field] !== "";
delete sanitized[field];
}

return { ...sanitized, ...sentinels };
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is untested as far as I can tell, lets add some tests for this to keep coverage up

editNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedBody = createNotificationBodyValidation.parse(req.body);
const validatedBody = editNotificationBodyValidation.parse(req.body);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Stripping the values out of the edit notiifcation seem to rely entirely on the client formatting the request correctly.

We have no idea what kind of client the user is going ot be interacting with, so we should not rely on that. We should also strip out values server side. Example, what happens if I craft a cURL request with a null for accessToken? I'm not actually sure, but we should definitely handle this server side and not depend on the client

- Declare each notification schema once, derive edit version using
  .partial() for sensitive fields (accessToken, accountSid)
- Removes duplicated schema definitions
- Add server-side stripping of undefined sensitive fields in edit
  controller so we don't rely on client behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@egeoztass
Copy link
Copy Markdown
Contributor Author

Both points addressed:

  1. DRY schemas — Each notification schema is declared once. Edit validation derives from create using .partial():

    matrixSchema.partial({ accessToken: true }),
    telegramSchema.partial({ accessToken: true }),
    pushoverSchema.partial({ accessToken: true }),
    twilioSchema.partial({ accessToken: true, accountSid: true }),

    This cut ~80 lines of duplication.

  2. Server-side stripping — The edit controller now strips undefined sensitive fields before passing to updateById, so we don't rely on the client to format the request correctly.

995/995 tests passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants