Skip to content

Commit 9bdfeba

Browse files
committed
Harden reader links and temporary signing files with regression checks
1 parent 65c083b commit 9bdfeba

9 files changed

Lines changed: 92 additions & 24 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ jobs:
5050
node-version: '22'
5151
- name: Accessibility contracts
5252
run: python3 -m unittest discover -s Tests/VoiceOverContracts
53+
- name: Reader links and signing key cleanup
54+
run: |
55+
node --test Tests/Readability/links.test.cjs
56+
python3 -m unittest discover -s Tests/BuildTools
5357
- name: MTProto malformed-input regression tests
5458
run: |
5559
clang -fobjc-arc -framework Foundation -I submodules/MtProtoKit/PublicHeaders \

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
## [Unreleased]
88

99
### Fixed — release hardening, VLESS and media playback
10+
- Reader mode removes mixed-case and control-character-obfuscated JavaScript links. Build tools atomically create temporary files; signing keys are removed even when certificate repository loading fails.
1011
- Fixed libxray API v3 decoding: successful replies contain a JSON object in `data` and an empty `error` string. The previous adapter rejected successful calls, preventing the embedded runtime from starting.
1112
- Protected Saved Messages now shares Archive's ten-tap reveal and password/biometric gate; entries in Settings, chat lists, search, sharing, widgets and Spotlight are hidden while protected as appropriate to each surface.
1213
- Archive cleanup removes the actual protected controller instead of blindly popping the top screen, clears its privacy cover after cleanup, isolates authentication across account switches, rejects late unlock callbacks after relock and retains legacy hashes if Keychain migration has not succeeded.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import ast
2+
import os
3+
import pathlib
4+
import tempfile
5+
import unittest
6+
7+
ROOT = pathlib.Path(__file__).resolve().parents[2]
8+
9+
class SigningTemporaryFileTests(unittest.TestCase):
10+
def test_key_is_private_and_deleted_after_clone_failure(self):
11+
# Compile the real class without importing the unrelated macOS tooling.
12+
tree = ast.parse((ROOT / 'build-system/Make/BuildConfiguration.py').read_text())
13+
definition = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'GitCodesigningSource')
14+
observed = []
15+
def clone(**kwargs):
16+
path = pathlib.Path(kwargs['temp_key_path'])
17+
observed.append(path)
18+
self.assertEqual(path.read_text(), 'test-only-private-key\n')
19+
if os.name != 'nt':
20+
self.assertEqual(path.stat().st_mode & 0o077, 0)
21+
raise RuntimeError('simulated clone failure')
22+
namespace = dict(os=os, tempfile=tempfile, CodesigningSource=object, load_codesigning_data_from_git=clone)
23+
exec(compile(ast.Module(body=[definition], type_ignores=[]), 'BuildConfiguration.py', 'exec'), namespace)
24+
source = namespace['GitCodesigningSource']('unused', 'test-only-private-key', 'team', 'bundle', 'adhoc', '', False)
25+
with self.assertRaisesRegex(RuntimeError, 'simulated clone failure'):
26+
source.load_data('unused')
27+
self.assertEqual(len(observed), 1)
28+
self.assertFalse(observed[0].exists())
29+
30+
if __name__ == '__main__':
31+
unittest.main()

‎Tests/Readability/links.test.cjs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const { test } = require('node:test');
2+
const assert = require('node:assert/strict');
3+
const Readability = require('../../submodules/TelegramUI/Resources/Readability/Readability.js');
4+
5+
function normalize(href) {
6+
let removed = false, result = href;
7+
const link = {
8+
getAttribute: () => href,
9+
setAttribute: (_, value) => { result = value; },
10+
childNodes: [{ nodeType: 3 }], textContent: 'link text',
11+
parentNode: { replaceChild: () => { removed = true; } },
12+
};
13+
const reader = Object.create(Readability.prototype);
14+
reader._doc = { baseURI: 'https://example.com/article', documentURI: 'https://example.com/article', createTextNode: text => ({ text }) };
15+
reader._getAllNodesWithTag = (_, tags) => tags[0] === 'a' ? [link] : [];
16+
reader._fixRelativeUris({});
17+
return { removed, result };
18+
}
19+
20+
test('reader removes executable URLs with case and control-character obfuscation', () => {
21+
for (const href of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', ' javascript:alert(1)', 'java\tscript:alert(1)', 'java\nscript:alert(1)', '\rjavascript:alert(1)']) {
22+
assert.equal(normalize(href).removed, true, JSON.stringify(href));
23+
}
24+
});
25+
26+
test('reader preserves navigation links and resolves relative URLs', () => {
27+
assert.deepEqual(normalize('../page'), { removed: false, result: 'https://example.com/page' });
28+
assert.deepEqual(normalize('#section'), { removed: false, result: '#section' });
29+
assert.equal(normalize('https://example.com/javascript:guide').removed, false);
30+
});

‎build-system/Make/BuildConfiguration.py‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -262,18 +262,19 @@ def __init__(self, repo_url, private_key, team_id, bundle_id, codesigning_type,
262262
def load_data(self, working_dir):
263263
self.working_dir = working_dir
264264
temp_key_path = None
265-
if self.private_key is not None:
266-
temp_key_path = tempfile.mktemp()
267-
with open(temp_key_path, 'w+') as file:
268-
file.write(self.private_key)
269-
if not self.private_key.endswith('\n'):
270-
file.write('\n')
271-
os.chmod(temp_key_path, 0o600)
272-
273-
load_codesigning_data_from_git(working_dir=self.working_dir, repo_url=self.repo_url, temp_key_path=temp_key_path, branch=self.team_id, password=self.password, always_fetch=self.always_fetch)
274-
275-
if temp_key_path is not None:
276-
os.remove(temp_key_path)
265+
try:
266+
if self.private_key is not None:
267+
key_fd, temp_key_path = tempfile.mkstemp()
268+
with os.fdopen(key_fd, 'w+') as file:
269+
file.write(self.private_key)
270+
if not self.private_key.endswith('\n'):
271+
file.write('\n')
272+
os.chmod(temp_key_path, 0o600)
273+
274+
load_codesigning_data_from_git(working_dir=self.working_dir, repo_url=self.repo_url, temp_key_path=temp_key_path, branch=self.team_id, password=self.password, always_fetch=self.always_fetch)
275+
finally:
276+
if temp_key_path is not None:
277+
os.remove(temp_key_path)
277278

278279
def copy_profiles_to_destination(self, destination_path):
279280
source_path = self.working_dir + '/decrypted/profiles/{}'.format(self.codesigning_type)

‎build-system/Make/GenerateProfiles.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ def get_certificate_base64_from_p12(p12_path, p12_password=''):
9999

100100
def process_provisioning_profile(source, destination, certificate_data, signing_identity, keychain_name):
101101
parsed_plist = run_executable_with_output('security', arguments=['cms', '-D', '-i', source], check_result=True)
102-
parsed_plist_file = tempfile.mktemp()
103-
with open(parsed_plist_file, 'w+') as file:
102+
plist_fd, parsed_plist_file = tempfile.mkstemp()
103+
with os.fdopen(plist_fd, 'w+') as file:
104104
file.write(parsed_plist)
105105

106106
# Remove all existing developer certificates

‎build-system/Make/RemoteBuild.py‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ def handle_ssh_credentials(credentials):
144144
if watch_provisioning_profile_remote_path is not None:
145145
guest_build_sh += '--watchProvisioningProfile="{}" \\'.format(watch_provisioning_profile_remote_path)
146146

147-
guest_build_file_path = tempfile.mktemp()
148-
with open(guest_build_file_path, 'w+') as file:
147+
guest_build_fd, guest_build_file_path = tempfile.mkstemp()
148+
with os.fdopen(guest_build_fd, 'w+') as file:
149149
file.write(guest_build_sh)
150150
session_scp_upload(session=session, source_path=guest_build_file_path, destination_path='guest-build-telegram.sh')
151151
os.unlink(guest_build_file_path)
@@ -222,8 +222,8 @@ def handle_ssh_credentials(credentials):
222222
FASTLANE_PASSWORD="{password}" xcrun altool --upload-app --type ios --file "Telegram.ipa" --username "{username}" --password "@env:FASTLANE_PASSWORD"
223223
'''.format(username=username, password=password)
224224

225-
guest_upload_file_path = tempfile.mktemp()
226-
with open(guest_upload_file_path, 'w+') as file:
225+
guest_upload_fd, guest_upload_file_path = tempfile.mkstemp()
226+
with os.fdopen(guest_upload_fd, 'w+') as file:
227227
file.write(guest_upload_sh)
228228
session_scp_upload(session=session, source_path=guest_upload_file_path, destination_path='guest-upload-telegram.sh')
229229
os.unlink(guest_upload_file_path)
@@ -282,15 +282,16 @@ def handle_ssh_credentials(credentials):
282282
echo $? > result.txt
283283
'''
284284

285-
guest_upload_file_path = tempfile.mktemp()
286-
with open(guest_upload_file_path, 'w+') as file:
285+
guest_upload_fd, guest_upload_file_path = tempfile.mkstemp()
286+
with os.fdopen(guest_upload_fd, 'w+') as file:
287287
file.write(guest_upload_sh)
288288
session_scp_upload(session=session, source_path=guest_upload_file_path, destination_path='guest-ipa-diff.sh')
289289
os.unlink(guest_upload_file_path)
290290

291291
print('Executing remote ipa-diff...')
292292
session_ssh(session=session, command='bash -l guest-ipa-diff.sh')
293-
guest_result_path = tempfile.mktemp()
293+
guest_result_fd, guest_result_path = tempfile.mkstemp()
294+
os.close(guest_result_fd)
294295
session_scp_download(session=session, source_path='result.txt', destination_path=guest_result_path)
295296
guest_result = ''
296297
with open(guest_result_path, 'r') as file:

‎build-system/Make/TartBuild.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,8 +611,8 @@ def remote_build_tart(macos_version, bazel_cache_host, configuration, build_inpu
611611
guest_build_sh += '--codesigningInformationPath=$HOME/telegram-build-input \\'
612612
guest_build_sh += '--outputBuildArtifactsPath=/Users/Shared/telegram-ios/build/artifacts \\'
613613

614-
guest_build_file_path = tempfile.mktemp()
615-
with open(guest_build_file_path, 'w+') as file:
614+
guest_build_fd, guest_build_file_path = tempfile.mkstemp()
615+
with os.fdopen(guest_build_fd, 'w+') as file:
616616
file.write(guest_build_sh)
617617
session.upload_file(local_path=guest_build_file_path, remote_path='guest-build-telegram.sh')
618618
os.unlink(guest_build_file_path)

‎submodules/TelegramUI/Resources/Readability/Readability.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,7 @@ Readability.prototype = {
463463
if (href) {
464464
// Remove links with javascript: URIs, since
465465
// they won't work after scripts have been removed from the page.
466-
if (href.indexOf("javascript:") === 0) {
466+
if (/^javascript:/i.test(href.replace(/[\u0000-\u0020]/g, ""))) {
467467
// if the link only contains simple text content, it can be converted to a text node
468468
if (
469469
link.childNodes.length === 1 &&

0 commit comments

Comments
 (0)