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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ RATELIMIT_REAUTH=5 per minute
SSH_CONNECT_RATELIMIT=10 per minute
# Per-user upload/replacement rate for encrypted SSH keys.
SSH_KEY_WRITE_RATELIMIT=30 per minute
# Shared per-user budget for key listings and rename/replace/delete refreshes.
# Checked before mutations so accepted changes still return an updated list.
SSH_KEY_LIST_RATELIMIT=30 per minute
# Per-account encrypted SSH-key limits. Existing over-limit stores remain
# readable and can still be renamed, deleted, or replaced with smaller keys.
SSH_KEY_MAX_RECORDS=100
Expand Down
67 changes: 47 additions & 20 deletions app/smb_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
DirectoryNotEmpty,
IOTimeout,
LogonFailure,
NoMoreFiles,
NoSuchFile,
ObjectNameCollision,
ObjectNameNotFound,
ObjectPathNotFound,
Expand All @@ -50,6 +52,7 @@
FileAttributes,
FileInformationClass,
FilePipePrinterAccessMask,
QueryDirectoryFlags,
)
from smbprotocol.session import Session, SessionFlags

Expand Down Expand Up @@ -242,21 +245,51 @@ def _entry_from_directory_info(raw_info):
)


def _directory_entries(raw, pattern):
"""Enumerate bounded pages on the already-verified handle, without DFS.

Keep progress checks below the filtering boundary: the high-level client
iterator can request another page before returning control to our caller.
Each directory may contain one '.' and one '..'; neither consumes the
caller's budget for ordinary entries.
"""
flags = QueryDirectoryFlags.SMB2_RESTART_SCANS
special_entries = set()
while True:
try:
page = raw.fd.query_directory(
pattern,
FileInformationClass.FILE_ID_FULL_DIRECTORY_INFORMATION,
flags=flags,
)
except (NoMoreFiles, NoSuchFile):
return
if not page:
raise SMBProtocolError('OPERATION_FAILED')
flags = 0
for raw_info in page:
entry = _entry_from_directory_info(raw_info)
if entry.name in {'.', '..'}:
if entry.name in special_entries:
raise SMBProtocolError('OPERATION_FAILED')
special_entries.add(entry.name)
continue
yield entry


def _query_exact_child(directory, name):
"""Resolve one exact child through an already-open parent handle."""
matches = []
for raw_info in directory.query_directory(
name,
FileInformationClass.FILE_ID_FULL_DIRECTORY_INFORMATION,
):
entry = _entry_from_directory_info(raw_info)
if entry.name in {'.', '..'}:
continue
if entry.name.casefold() != name.casefold():
raise SMBProtocolError('CONFLICT')
matches.append(entry)
if len(matches) > 1:
raise SMBProtocolError('CONFLICT')
entries = _directory_entries(directory, name)
try:
for entry in entries:
if entry.name.casefold() != name.casefold():
raise SMBProtocolError('CONFLICT')
matches.append(entry)
if len(matches) > 1:
raise SMBProtocolError('CONFLICT')
finally:
entries.close()
if not matches:
raise SMBProtocolError('NOT_FOUND')
return matches[0]
Expand Down Expand Up @@ -795,10 +828,7 @@ def __init__(
None if connection_kwargs is None else dict(connection_kwargs)
)
try:
self._iterator = raw.query_directory(
'*',
FileInformationClass.FILE_ID_FULL_DIRECTORY_INFORMATION,
)
self._iterator = _directory_entries(raw, '*')
except BaseException:
try:
raw.close()
Expand All @@ -816,10 +846,7 @@ def __next__(self):
if self.closed:
raise StopIteration
try:
while True:
entry = _entry_from_directory_info(next(self._iterator))
if entry.name not in {'.', '..'}:
return entry
return next(self._iterator)
except StopIteration:
self.close()
raise
Expand Down
45 changes: 40 additions & 5 deletions app/socket_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -2573,13 +2573,39 @@ def handle_delete_jump_host(data, current_user=None):
log_error("Failed to delete jump host", error=str(e))
emit('error', {'error': 'Failed to delete jump host'})

def _key_summary_rate_limit(current_user):
if check_socket_rate_limit(
current_user.id,
'ssh_key_list',
config.RATELIMIT_SSH_KEY_LIST,
):
return _key_mutation_error(
'Too many SSH key list requests. Please wait a moment.'
)
return None


def _emit_key_summaries(current_user):
"""Emit fresh usability after the caller reserves one summary operation."""
try:
keys = key_manager.load_key_summaries(current_user.id)
emit('keys_list', {'keys': keys})
except StorageCorruptionError as error:
return _emit_storage_error(error, current_user)
except Exception as e:
log_error("Failed to load keys", error=str(e))
emit('error', {'error': 'Failed to load keys'})


@socketio.on('list_keys')
@socket_login_required
def handle_list_keys(current_user=None):
"""Return list of stored SSH keys for this user."""
try:
keys = key_manager.load_key_summaries(current_user.id)
emit('keys_list', {'keys': keys})
limited = _key_summary_rate_limit(current_user)
if limited:
return limited
return _emit_key_summaries(current_user)
except StorageCorruptionError as error:
return _emit_storage_error(error, current_user)
except Exception as e:
Expand Down Expand Up @@ -2639,6 +2665,9 @@ def handle_upload_key(data, current_user=None):
def handle_rename_key(data, current_user=None):
"""Rename one owned SSH key without exposing its encrypted contents."""
try:
limited = _key_summary_rate_limit(current_user)
if limited:
return limited
data = data if isinstance(data, dict) else {}
result, error = key_manager.rename_key(
current_user.id,
Expand All @@ -2656,7 +2685,7 @@ def handle_rename_key(data, current_user=None):
)
payload = {'success': True, 'key': result['key']}
emit('key_renamed', payload)
handle_list_keys(current_user=current_user)
_emit_key_summaries(current_user)
return payload
except StorageCorruptionError as error:
return _emit_storage_error(error, current_user)
Expand All @@ -2669,6 +2698,9 @@ def handle_rename_key(data, current_user=None):
def handle_replace_key(data, current_user=None):
"""Replace one owned SSH key without changing its stable identity."""
try:
limited = _key_summary_rate_limit(current_user)
if limited:
return limited
data = data if isinstance(data, dict) else {}
key_id = data.get('key_id')
key_content = data.get('key_content')
Expand Down Expand Up @@ -2715,7 +2747,7 @@ def handle_replace_key(data, current_user=None):
)
payload = {'success': True, 'key': key}
emit('key_replaced', payload)
handle_list_keys(current_user=current_user)
_emit_key_summaries(current_user)
return payload
except StorageCorruptionError as error:
return _emit_storage_error(error, current_user)
Expand All @@ -2727,6 +2759,9 @@ def handle_replace_key(data, current_user=None):
def handle_delete_key(data, current_user=None):
"""Delete an SSH key for this user."""
try:
limited = _key_summary_rate_limit(current_user)
if limited:
return limited
key_id = data.get('key_id')
if not key_id:
emit('error', {'error': 'Key ID required'})
Expand All @@ -2736,7 +2771,7 @@ def handle_delete_key(data, current_user=None):
if success:
log_key_delete(current_user.username, key_id, request.remote_addr)
emit('key_deleted', {'key_id': key_id})
handle_list_keys(current_user=current_user)
_emit_key_summaries(current_user)
else:
emit('error', {'error': 'Failed to delete key'})

Expand Down
3 changes: 3 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,9 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots):
RATELIMIT_SSH_KEY_WRITE = os.environ.get(
'SSH_KEY_WRITE_RATELIMIT', '30 per minute'
)
RATELIMIT_SSH_KEY_LIST = os.environ.get(
'SSH_KEY_LIST_RATELIMIT', '30 per minute'
)
RATELIMIT_COMMAND_MUTATION = os.environ.get(
'COMMAND_MUTATION_RATELIMIT',
'60 per minute',
Expand Down
7 changes: 7 additions & 0 deletions docs/wiki/Configuration-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,15 @@ Connection, transfer, background-work, and thread limits form one capacity model
| `RATELIMIT_REAUTH` | `5 per minute` |
| `SSH_CONNECT_RATELIMIT` | `10 per minute` |
| `SSH_KEY_WRITE_RATELIMIT` | `30 per minute` |
| `SSH_KEY_LIST_RATELIMIT` | `30 per minute` |
| `CONNECTION_MUTATION_RATELIMIT` | `60 per minute` |

Key listings and the refresh after renaming, replacing, or deleting a key share
`SSH_KEY_LIST_RATELIMIT` per user across browser connections. Admission is checked
before the mutation; when exhausted, the change is rejected without altering the
key. Accepted changes retain their acknowledgement and updated key list. Usability
is still checked against the current key files, without caching decrypted keys.

## SSH key and live-output limits

| Variable | Default |
Expand Down
10 changes: 7 additions & 3 deletions static/js/sftp-file-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -6008,9 +6008,13 @@ class SFTPFileManager {
}

escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
// Used in both text and quoted attributes in the file workspace.
return String(text ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

showUploadProgress(batch = this.currentUploadBatch) {
Expand Down
45 changes: 45 additions & 0 deletions tests/e2e/file-workspace.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,51 @@ async function openWorkspaceWithSources(page) {
});
}

for (const kind of ['sftp', 'smb']) {
test(`${kind} filenames preserve text, checkbox attributes and sorted selection`, async ({ page }) => {
await openWorkspaceWithSources(page);
// Ordinary punctuation and entity-like text, without executable markup.
const filename = 'Über "quotes" and \'apostrophes\' & <notes> &quot;.txt';
await page.evaluate(({ kind, filename }) => {
const manager = window.sftpFileManager;
manager.closeSourceLauncher();
const sourceId = kind === 'sftp'
? 'sftp-session:workspace-source'
: `smb-quick:${'a'.repeat(32)}`;
Object.assign(manager.panes.left, manager.createEmptyPaneState(), {
source: {
sourceId, kind, label: 'Punctuation files',
capabilities: ['list', 'read'], security: {}, access: {},
},
path: '/',
files: [
{ name: filename, is_dir: false, size: 12 },
{ name: 'Folder', is_dir: true, size: 0 },
{ name: 'alpha.txt', is_dir: false, size: 1 },
],
});
manager.renderPane('left');
}, { kind, filename });

const rows = page.locator('#fmLeftList .fm-file-item');
await expect(rows).toHaveCount(3);
await expect(rows.first().locator('.fm-file-name')).toHaveText('Folder');
const row = page.locator('#fmLeftList .fm-file-item[data-index="0"]');
await expect(row.locator('.fm-file-name')).toHaveText(filename);
const checkbox = row.getByRole('checkbox');
await expect(checkbox).toHaveAttribute('aria-label', `Select: ${filename}`);
expect(await checkbox.evaluate(element => element.getAttributeNames().sort())).toEqual([
'aria-checked', 'aria-label', 'class', 'role', 'type',
]);
await checkbox.click();
await expect(checkbox).toHaveAttribute('aria-checked', 'true');
expect(await page.evaluate(() => [...window.sftpFileManager.panes.left.selected])).toEqual([0]);
await checkbox.click();
await expect(checkbox).toHaveAttribute('aria-checked', 'false');
await assertNoExternalRequests(page);
});
}

test('source-first workspace preserves panes and exposes only functional SFTP actions', async ({ page }) => {
await openWorkspaceWithSources(page);

Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/run_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,9 @@ def main():
'REGISTRATION_ENABLED': 'True',
'RATELIMIT_STORAGE_URL': 'memory://',
'RATELIMIT_LOGIN_LIMIT': '100 per minute',
# Browser cases share seeded accounts and open them in bursts.
# Budget enforcement is exercised by the socket contract tests.
'SSH_KEY_LIST_RATELIMIT': '100 per minute',
'CORS_ORIGINS': (
'http://127.0.0.1:'
+ os.environ.get('WEBSSH_E2E_PORT', '4173')
Expand Down
7 changes: 0 additions & 7 deletions tests/js/sftp-transfer-queue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4064,13 +4064,6 @@ test('remote filenames never enter attributes even when they contain quote and e
},
workspace: { layout: 'single' },
displayMode: 'embedded',
escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
},
updatePaneStatus() {},
t(_key, fallback) { return fallback; },
});
Expand Down
Loading
Loading