Harden CSS/URL handling, NWC storage, and Android backup

- Sanitize event-sourced URLs before CSS url() interpolation in
  ProfileCard banner and letter stationery background (closes H-1, H-2)
- Sanitize event-sourced font families at the parse layer and in letter
  card/detail consumers that bypass resolveStationery (closes M-6)
- Export sanitizeCssString for broader reuse
- Route NWC wallet connection URIs and active pointer through a new
  useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore
  on native (closes M-1)
- Add removeItem to secureStorage
- Add Android backup/data-extraction rules that exclude WebView storage
  and Capacitor secure-storage SharedPreferences so wallet credentials
  don't leak via Google Auto Backup (closes M-5)
- Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded
  to match what the CI job expects (closes M-2)
This commit is contained in:
Alex Gleason
2026-04-16 14:20:26 -05:00
parent a61925b821
commit 0d3b8ed23d
14 changed files with 269 additions and 20 deletions
+12 -2
View File
@@ -1596,7 +1596,7 @@ The project automatically publishes Android AABs (App Bundles) to [Google Play](
| Variable | Description | Protected | Masked | Raw |
|---|---|---|---|---|
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Google Play API service account key file | Yes | Yes | No |
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON file. The CI job decodes it with `base64 -d` before passing it to `fastlane supply`. | Yes | Yes | No |
#### Initial Setup (one-time)
@@ -1604,7 +1604,17 @@ The project automatically publishes Android AABs (App Bundles) to [Google Play](
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`
5. Add the full JSON contents of the key file as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**.
5. **Base64-encode** the key file:
```bash
# Linux
base64 -w0 service-account.json
# macOS
base64 -i service-account.json | tr -d '\n'
```
6. Add the base64-encoded value as the `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` variable in GitLab CI/CD settings (Settings > CI/CD > Variables). Mark it as **Protected** and **Masked**. Do **not** paste the raw JSON — the CI script expects base64 and will fail to decode a raw value.
#### Key Points
+2
View File
@@ -3,6 +3,8 @@
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android Auto Backup rules (Android 11 and below).
Ditto excludes WebView storage (Local Storage, IndexedDB, databases) and
any shared_prefs that hold sensitive credentials so they don't end up in
Google Drive backups. Keychain/KeyStore entries used by
capacitor-secure-storage-plugin are not backed up by default, so we don't
need to exclude those explicitly; but we also exclude the plugin's
SharedPreferences for defense in depth.
See: https://developer.android.com/guide/topics/data/autobackup
-->
<full-backup-content>
<!-- WebView: localStorage, IndexedDB, cookies, caches -->
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<!-- capacitor-secure-storage-plugin fallback SharedPreferences -->
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<!-- Capacitor preferences plugin — may contain app-level settings -->
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</full-backup-content>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 12+ data extraction rules.
Separate rules apply to cloud backups (Google Drive) and device-to-device
transfers. Both exclude WebView storage and sensitive SharedPreferences so
wallet credentials, login tokens, and cached private data don't leak.
See: https://developer.android.com/about/versions/12/backup-restore
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="app_webview/" />
<exclude domain="database" path="webview.db" />
<exclude domain="database" path="webviewCache.db" />
<exclude domain="sharedpref" path="CapacitorSecureStoragePluginSharedPreferences.xml" />
<exclude domain="sharedpref" path="CapacitorStorage.xml" />
</device-transfer>
</data-extraction-rules>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ditto",
"version": "2.6.6",
"version": "2.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ditto",
"version": "2.6.6",
"version": "2.7.0",
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
+6 -2
View File
@@ -18,6 +18,7 @@ import { useProfileBadges } from '@/hooks/useProfileBadges';
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
import { BadgeShowcaseGrid } from '@/components/BadgeShowcaseGrid';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** Shared classes for all editable fields — static muted bg when idle, border on hover/focus */
const editableBase = [
@@ -129,6 +130,9 @@ export function ProfileCard({
const initial = displayName[0]?.toUpperCase() ?? '?';
const patch = (key: keyof NostrMetadata) => (v: string) => onChange?.({ [key]: v });
// Sanitize banner URL from untrusted metadata before CSS url() interpolation
const bannerUrl = sanitizeUrl(metadata.banner);
// Read shape from metadata (it's a custom property passed through the loose schema)
const rawShape = metadata.shape;
const shape: AvatarShape | undefined = isValidAvatarShape(rawShape) ? rawShape : undefined;
@@ -187,8 +191,8 @@ export function ProfileCard({
<div
className={cn('relative h-36 bg-secondary', editable && 'cursor-pointer group')}
style={
metadata.banner
? { backgroundImage: `url(${metadata.banner})`, backgroundSize: 'cover', backgroundPosition: 'center' }
bannerUrl
? { backgroundImage: `url("${bannerUrl}")`, backgroundSize: 'cover', backgroundPosition: 'center' }
: undefined
}
onClick={() => editable && onPickImage?.('banner')}
+5 -1
View File
@@ -11,6 +11,7 @@ import { toast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { FONT_OPTIONS, LETTER_KIND, LINE_HEIGHT_RATIO, type Letter } from '@/lib/letterTypes';
import { ensureLetterFonts } from '@/lib/letterUtils';
import { sanitizeCssString } from '@/lib/fontLoader';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
@@ -98,7 +99,10 @@ export function LetterCard({ letter, mode }: LetterCardProps) {
const timeAgo = formatDistanceToNow(new Date(letter.timestamp * 1000), { addSuffix: true });
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
const rawFont = effectiveStationery?.fontFamily;
// Sanitize event-sourced font family before CSS interpolation (M-6).
const rawFont = effectiveStationery?.fontFamily
? sanitizeCssString(effectiveStationery.fontFamily)
: undefined;
const letterFontFamily = rawFont
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
: FONT_OPTIONS[0].family;
+5 -1
View File
@@ -12,6 +12,7 @@ import { FONT_OPTIONS, LINE_HEIGHT_RATIO, COLOR_MOMENT_KIND, THEME_KIND, resolve
import { hexLuminance, backgroundTextColor } from '@/lib/colorUtils';
import { ColorPaletteDisplay, type PaletteLayout } from './ColorPaletteDisplay';
import { ensureLetterFonts } from '@/lib/letterUtils';
import { sanitizeCssString } from '@/lib/fontLoader';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
@@ -204,7 +205,10 @@ export function LetterDetailSheet({ letter, onClose, onReply }: LetterDetailShee
const effectiveFrameTint = effectiveStationery?.frameTint;
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
const rawFont = effectiveStationery?.fontFamily;
// Sanitize event-sourced font family before CSS interpolation (M-6).
const rawFont = effectiveStationery?.fontFamily
? sanitizeCssString(effectiveStationery.fontFamily)
: undefined;
const letterFontFamily = rawFont
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
: FONT_OPTIONS[0].family;
@@ -199,7 +199,7 @@ export function StationeryBackground({
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundImage: `url("${s.imageUrl}")`,
backgroundSize: s.imageMode === 'tile' ? 'auto' : 'cover',
backgroundPosition: 'center',
backgroundRepeat: s.imageMode === 'tile' ? 'repeat' : 'no-repeat',
@@ -243,7 +243,7 @@ function ThemeMockup({ stationery }: { stationery: Stationery }) {
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundImage: `url("${s.imageUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
opacity: 0.35,
@@ -312,7 +312,7 @@ export function StationeryPreview({
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${s.imageUrl})`,
backgroundImage: `url("${s.imageUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
+7 -3
View File
@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage';
import { useToast } from '@/hooks/useToast';
import { LN } from '@getalby/sdk';
@@ -26,8 +26,12 @@ export function useNWCInternal(userPubkey?: string) {
// be accessible without a user anyway since zap actions require login).
const storagePrefix = userPubkey ? `nwc-connections:${userPubkey}` : 'nwc-connections';
const activePrefix = userPubkey ? `nwc-active-connection:${userPubkey}` : 'nwc-active-connection';
const [connections, setConnections] = useLocalStorage<NWCConnection[]>(storagePrefix, []);
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>(activePrefix, null);
// NWC connection strings embed a secret that authorizes Lightning payments,
// so on native platforms we store them in Keychain/KeyStore via secureStorage
// rather than plaintext localStorage. On web the behavior matches the
// previous useLocalStorage implementation (plaintext localStorage).
const [connections, setConnections] = useSecureLocalStorage<NWCConnection[]>(storagePrefix, []);
const [activeConnection, setActiveConnection] = useSecureLocalStorage<string | null>(activePrefix, null);
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
// Add new connection
+145
View File
@@ -0,0 +1,145 @@
import { useState, useEffect, useRef } from 'react';
import { Capacitor } from '@capacitor/core';
import { secureStorage } from '@/lib/secureStorage';
/**
* Hook for storing sensitive state that should not live in plaintext
* localStorage on native platforms.
*
* - On web: uses `localStorage` (identical to `useLocalStorage`).
* - On native (Capacitor iOS/Android): uses the native Keychain / KeyStore via
* `secureStorage`. Any existing plaintext value in localStorage for the same
* key is migrated on first read and the plaintext copy is removed.
*
* This is async under the hood, so the hook additionally exposes a `ready`
* flag indicating whether the initial load has completed. While `!ready`, the
* state is `defaultValue` and callers should avoid making decisions based on
* an "empty" state (e.g. do not persist a default back if the user has real
* data stored that hasn't been loaded yet).
*
* The return tuple is `[state, setValue, ready]`.
*/
export function useSecureLocalStorage<T>(
key: string,
defaultValue: T,
serializer?: {
serialize: (value: T) => string;
deserialize: (value: string) => T;
},
) {
const serialize = serializer?.serialize || JSON.stringify;
const deserialize = serializer?.deserialize || JSON.parse;
const isNative = Capacitor.isNativePlatform();
// On web we can read synchronously during initialization (same behavior as
// useLocalStorage). On native we must wait for the async read, so we start
// with defaultValue and flip `ready` once loaded.
const [state, setState] = useState<T>(() => {
if (isNative) return defaultValue;
try {
const item = localStorage.getItem(key);
return item ? deserialize(item) : defaultValue;
} catch (error) {
console.warn(`Failed to load ${key} from localStorage:`, error);
return defaultValue;
}
});
const [ready, setReady] = useState<boolean>(!isNative);
// Track the most-recently-requested key so stale async reads don't clobber
// state after the caller swapped to a different key.
const currentKeyRef = useRef(key);
currentKeyRef.current = key;
useEffect(() => {
let cancelled = false;
async function load() {
try {
if (isNative) {
// Check secureStorage first (handles migration from plaintext
// localStorage internally per secureStorage.getItem).
const item = await secureStorage.getItem(key);
if (cancelled || currentKeyRef.current !== key) return;
setState(item ? deserialize(item) : defaultValue);
} else {
const item = localStorage.getItem(key);
if (cancelled || currentKeyRef.current !== key) return;
setState(item ? deserialize(item) : defaultValue);
}
} catch (error) {
if (!cancelled) {
console.warn(`Failed to load ${key} from secure storage:`, error);
setState(defaultValue);
}
} finally {
if (!cancelled) setReady(true);
}
}
setReady(false);
load();
return () => {
cancelled = true;
};
// defaultValue and deserialize are intentionally excluded — we only want to
// re-read when the key identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, isNative]);
const setValue = (value: T | ((prev: T) => T)) => {
const persist = (next: T) => {
try {
const serialized = serialize(next);
if (isNative) {
// Fire-and-forget; errors are logged but shouldn't block the caller.
void secureStorage.setItem(key, serialized).catch((error) => {
console.warn(`Failed to save ${key} to secure storage:`, error);
});
} else {
localStorage.setItem(key, serialized);
}
} catch (error) {
console.warn(`Failed to serialize ${key}:`, error);
}
};
if (value instanceof Function) {
setState((prev) => {
const next = (value as (p: T) => T)(prev);
if (next === prev) return prev;
persist(next);
return next;
});
} else {
setState((prev) => {
if (value === prev) return prev;
persist(value);
return value;
});
}
};
// Sync with cross-tab changes on web. Native secure storage has no
// cross-tab concept.
useEffect(() => {
if (isNative) return;
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setState(deserialize(e.newValue));
} catch (error) {
console.warn(`Failed to sync ${key} from localStorage:`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, isNative, deserialize]);
return [state, setValue, ready] as const;
}
+4 -1
View File
@@ -17,8 +17,11 @@ import { findBundledFont, loadBundledFont, resolveCssFamily } from '@/lib/fonts'
* Sanitize a string for safe interpolation into a double-quoted CSS context.
* Uses an allowlist approach — only Unicode letters, numbers, spaces, hyphens,
* underscores, apostrophes, and periods are permitted. Everything else is stripped.
*
* Use whenever event-sourced strings flow into a CSS declaration value
* (e.g. `font-family`) to prevent CSS-string breakout.
*/
function sanitizeCssString(value: string): string {
export function sanitizeCssString(value: string): string {
return value.replace(/[^\p{L}\p{N} _\-'.]/gu, '');
}
+14 -5
View File
@@ -1,4 +1,6 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { sanitizeCssString } from '@/lib/fontLoader';
export const LETTER_KIND = 8211;
export const COLOR_MOMENT_KIND = 3367;
@@ -96,11 +98,15 @@ export interface ResolvedStationery {
/** Resolve a Stationery into full rendering attributes by reading event tags. */
export function resolveStationery(s: Stationery): ResolvedStationery {
// Sanitize event-sourced font family before CSS interpolation (see SECURITY_AUDIT M-6).
// Applied at the parse layer so every consumer gets a safe value.
const safeFontFamily = s.fontFamily ? sanitizeCssString(s.fontFamily) : undefined;
const base: ResolvedStationery = {
color: s.color,
emoji: s.emoji,
emojiMode: s.emojiMode ?? 'tile',
fontFamily: s.fontFamily,
fontFamily: safeFontFamily,
frame: s.frame,
frameTint: s.frameTint,
imageMode: 'cover',
@@ -131,22 +137,25 @@ export function resolveStationery(s: Stationery): ResolvedStationery {
const bgTag = event.tags.find(([n]) => n === 'bg');
if (bgTag) {
for (const slot of bgTag.slice(1)) {
if (slot.startsWith('url ')) base.imageUrl = slot.slice(4);
// Sanitize event-sourced URL before CSS `url(...)` interpolation (H-2).
if (slot.startsWith('url ')) base.imageUrl = sanitizeUrl(slot.slice(4));
else if (slot === 'mode tile') base.imageMode = 'tile';
else if (slot === 'mode cover') base.imageMode = 'cover';
}
}
if (!base.imageUrl) {
base.imageUrl = event.tags.find(([n]) => n === 'image')?.[1];
base.imageUrl = sanitizeUrl(event.tags.find(([n]) => n === 'image')?.[1]);
}
return base;
}
// No event or unknown kind — use legacy flat fallbacks (old letters, presets)
// No event or unknown kind — use legacy flat fallbacks (old letters, presets).
// Legacy `imageUrl` may carry user-supplied URLs from pre-sanitization letters,
// so sanitize here as well for defense-in-depth.
base.textColor = s.textColor;
base.primaryColor = s.primaryColor;
base.layout = s.layout;
base.imageUrl = s.imageUrl;
base.imageUrl = sanitizeUrl(s.imageUrl);
base.imageMode = s.imageMode ?? 'cover';
return base;
}
+13
View File
@@ -41,4 +41,17 @@ export const secureStorage = {
await SecureStoragePlugin.set({ key, value });
},
async removeItem(key: string): Promise<void> {
if (!Capacitor.isNativePlatform()) {
localStorage.removeItem(key);
return;
}
try {
await SecureStoragePlugin.remove({ key });
} catch {
// Key didn't exist — ignore.
}
},
};