@superblocksteam/shared 0.9609.6 → 2.0.0-SNAPSHOT.7ea2658f98892d2c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/database-lifecycle/destructiveMigrations.d.ts +1 -0
- package/dist/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
- package/dist/database-lifecycle/destructiveMigrations.js +21 -1
- package/dist/database-lifecycle/destructiveMigrations.js.map +1 -1
- package/dist/database-lifecycle/destructiveMigrations.test.js +22 -0
- package/dist/database-lifecycle/destructiveMigrations.test.js.map +1 -1
- package/dist/socket/heartbeat.d.ts +10 -0
- package/dist/socket/heartbeat.d.ts.map +1 -1
- package/dist/socket/heartbeat.js +5 -0
- package/dist/socket/heartbeat.js.map +1 -1
- package/dist/socket/heartbeat.test.js +36 -0
- package/dist/socket/heartbeat.test.js.map +1 -1
- package/dist/socket/protocol.d.ts +2 -2
- package/dist/socket/protocol.d.ts.map +1 -1
- package/dist/socket/protocol.js.map +1 -1
- package/dist/socket/socket.d.ts +13 -0
- package/dist/socket/socket.d.ts.map +1 -1
- package/dist/socket/socket.js +21 -2
- package/dist/socket/socket.js.map +1 -1
- package/dist/socket/socket.test.js +33 -0
- package/dist/socket/socket.test.js.map +1 -1
- package/dist/types/codeSecurity/index.d.ts +6 -0
- package/dist/types/codeSecurity/index.d.ts.map +1 -1
- package/dist/types/codeSecurity/index.js +23 -0
- package/dist/types/codeSecurity/index.js.map +1 -1
- package/dist/types/codeSecurity/index.test.js +48 -0
- package/dist/types/codeSecurity/index.test.js.map +1 -1
- package/dist/types/policyGate/errors.d.ts +70 -0
- package/dist/types/policyGate/errors.d.ts.map +1 -0
- package/dist/types/policyGate/errors.js +42 -0
- package/dist/types/policyGate/errors.js.map +1 -0
- package/dist/types/policyGate/index.d.ts +3 -0
- package/dist/types/policyGate/index.d.ts.map +1 -1
- package/dist/types/policyGate/index.js +22 -0
- package/dist/types/policyGate/index.js.map +1 -1
- package/dist/types/publishPipeline/displayNames.js +6 -6
- package/dist/types/publishPipeline/displayNames.js.map +1 -1
- package/dist/types/publishPipeline/displayNames.test.js +5 -5
- package/dist/types/publishPipeline/displayNames.test.js.map +1 -1
- package/dist/types/publishPipeline/index.d.ts +3 -3
- package/dist/types/publishPipeline/index.d.ts.map +1 -1
- package/dist/types/publishPipeline/plan.d.ts +1 -11
- package/dist/types/publishPipeline/plan.d.ts.map +1 -1
- package/dist/types/publishPipeline/plan.js +2 -57
- package/dist/types/publishPipeline/plan.js.map +1 -1
- package/dist/types/publishPipeline/plan.test.js +9 -95
- package/dist/types/publishPipeline/plan.test.js.map +1 -1
- package/dist/types/response/PostApplicationCloneResponseBody.d.ts +12 -0
- package/dist/types/response/PostApplicationCloneResponseBody.d.ts.map +1 -0
- package/dist/types/response/PostApplicationCloneResponseBody.js +3 -0
- package/dist/types/response/PostApplicationCloneResponseBody.js.map +1 -0
- package/dist/types/response/index.d.ts +1 -0
- package/dist/types/response/index.d.ts.map +1 -1
- package/dist/types/response/index.js +1 -0
- package/dist/types/response/index.js.map +1 -1
- package/dist/types/reviewPolicy/index.d.ts +5 -0
- package/dist/types/reviewPolicy/index.d.ts.map +1 -1
- package/dist/types/reviewPolicy/index.js.map +1 -1
- package/dist/utils/attachment-upload.d.ts +40 -0
- package/dist/utils/attachment-upload.d.ts.map +1 -1
- package/dist/utils/attachment-upload.js +90 -1
- package/dist/utils/attachment-upload.js.map +1 -1
- package/dist/utils/attachment-upload.test.js +61 -0
- package/dist/utils/attachment-upload.test.js.map +1 -1
- package/dist/utils/git-url.d.ts.map +1 -1
- package/dist/utils/git-url.js +29 -6
- package/dist/utils/git-url.js.map +1 -1
- package/dist/utils/git-url.test.js +60 -0
- package/dist/utils/git-url.test.js.map +1 -1
- package/dist/utils/normalizeRoleName.js +2 -2
- package/dist/utils/normalizeRoleName.js.map +1 -1
- package/dist/utils/normalizeRoleName.test.d.ts +2 -0
- package/dist/utils/normalizeRoleName.test.d.ts.map +1 -0
- package/dist/utils/normalizeRoleName.test.js +25 -0
- package/dist/utils/normalizeRoleName.test.js.map +1 -0
- package/dist-esm/constants.d.ts +1 -0
- package/dist-esm/constants.d.ts.map +1 -1
- package/dist-esm/constants.js +1 -0
- package/dist-esm/constants.js.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.d.ts +1 -0
- package/dist-esm/database-lifecycle/destructiveMigrations.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.js +21 -2
- package/dist-esm/database-lifecycle/destructiveMigrations.js.map +1 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.test.js +23 -1
- package/dist-esm/database-lifecycle/destructiveMigrations.test.js.map +1 -1
- package/dist-esm/socket/heartbeat.d.ts +10 -0
- package/dist-esm/socket/heartbeat.d.ts.map +1 -1
- package/dist-esm/socket/heartbeat.js +5 -0
- package/dist-esm/socket/heartbeat.js.map +1 -1
- package/dist-esm/socket/heartbeat.test.js +36 -0
- package/dist-esm/socket/heartbeat.test.js.map +1 -1
- package/dist-esm/socket/protocol.d.ts +2 -2
- package/dist-esm/socket/protocol.d.ts.map +1 -1
- package/dist-esm/socket/protocol.js.map +1 -1
- package/dist-esm/socket/socket.d.ts +13 -0
- package/dist-esm/socket/socket.d.ts.map +1 -1
- package/dist-esm/socket/socket.js +20 -1
- package/dist-esm/socket/socket.js.map +1 -1
- package/dist-esm/socket/socket.test.js +33 -0
- package/dist-esm/socket/socket.test.js.map +1 -1
- package/dist-esm/types/codeSecurity/index.d.ts +6 -0
- package/dist-esm/types/codeSecurity/index.d.ts.map +1 -1
- package/dist-esm/types/codeSecurity/index.js +22 -0
- package/dist-esm/types/codeSecurity/index.js.map +1 -1
- package/dist-esm/types/codeSecurity/index.test.js +49 -1
- package/dist-esm/types/codeSecurity/index.test.js.map +1 -1
- package/dist-esm/types/policyGate/errors.d.ts +70 -0
- package/dist-esm/types/policyGate/errors.d.ts.map +1 -0
- package/dist-esm/types/policyGate/errors.js +37 -0
- package/dist-esm/types/policyGate/errors.js.map +1 -0
- package/dist-esm/types/policyGate/index.d.ts +3 -0
- package/dist-esm/types/policyGate/index.d.ts.map +1 -1
- package/dist-esm/types/policyGate/index.js +7 -0
- package/dist-esm/types/policyGate/index.js.map +1 -1
- package/dist-esm/types/publishPipeline/displayNames.js +6 -6
- package/dist-esm/types/publishPipeline/displayNames.js.map +1 -1
- package/dist-esm/types/publishPipeline/displayNames.test.js +5 -5
- package/dist-esm/types/publishPipeline/displayNames.test.js.map +1 -1
- package/dist-esm/types/publishPipeline/index.d.ts +3 -3
- package/dist-esm/types/publishPipeline/index.d.ts.map +1 -1
- package/dist-esm/types/publishPipeline/plan.d.ts +1 -11
- package/dist-esm/types/publishPipeline/plan.d.ts.map +1 -1
- package/dist-esm/types/publishPipeline/plan.js +2 -56
- package/dist-esm/types/publishPipeline/plan.js.map +1 -1
- package/dist-esm/types/publishPipeline/plan.test.js +10 -96
- package/dist-esm/types/publishPipeline/plan.test.js.map +1 -1
- package/dist-esm/types/response/PostApplicationCloneResponseBody.d.ts +12 -0
- package/dist-esm/types/response/PostApplicationCloneResponseBody.d.ts.map +1 -0
- package/dist-esm/types/response/PostApplicationCloneResponseBody.js +2 -0
- package/dist-esm/types/response/PostApplicationCloneResponseBody.js.map +1 -0
- package/dist-esm/types/response/index.d.ts +1 -0
- package/dist-esm/types/response/index.d.ts.map +1 -1
- package/dist-esm/types/response/index.js +1 -0
- package/dist-esm/types/response/index.js.map +1 -1
- package/dist-esm/types/reviewPolicy/index.d.ts +5 -0
- package/dist-esm/types/reviewPolicy/index.d.ts.map +1 -1
- package/dist-esm/types/reviewPolicy/index.js.map +1 -1
- package/dist-esm/utils/attachment-upload.d.ts +40 -0
- package/dist-esm/utils/attachment-upload.d.ts.map +1 -1
- package/dist-esm/utils/attachment-upload.js +85 -0
- package/dist-esm/utils/attachment-upload.js.map +1 -1
- package/dist-esm/utils/attachment-upload.test.js +62 -1
- package/dist-esm/utils/attachment-upload.test.js.map +1 -1
- package/dist-esm/utils/git-url.d.ts.map +1 -1
- package/dist-esm/utils/git-url.js +29 -6
- package/dist-esm/utils/git-url.js.map +1 -1
- package/dist-esm/utils/git-url.test.js +60 -0
- package/dist-esm/utils/git-url.test.js.map +1 -1
- package/dist-esm/utils/normalizeRoleName.js +2 -2
- package/dist-esm/utils/normalizeRoleName.js.map +1 -1
- package/dist-esm/utils/normalizeRoleName.test.d.ts +2 -0
- package/dist-esm/utils/normalizeRoleName.test.d.ts.map +1 -0
- package/dist-esm/utils/normalizeRoleName.test.js +23 -0
- package/dist-esm/utils/normalizeRoleName.test.js.map +1 -0
- package/package.json +1 -1
- package/src/constants.ts +1 -0
- package/src/database-lifecycle/destructiveMigrations.test.ts +27 -1
- package/src/database-lifecycle/destructiveMigrations.ts +22 -2
- package/src/socket/heartbeat.test.ts +44 -0
- package/src/socket/heartbeat.ts +15 -0
- package/src/socket/protocol.ts +2 -1
- package/src/socket/socket.test.ts +43 -0
- package/src/socket/socket.ts +22 -1
- package/src/types/codeSecurity/index.test.ts +58 -1
- package/src/types/codeSecurity/index.ts +38 -0
- package/src/types/policyGate/errors.ts +51 -0
- package/src/types/policyGate/index.ts +12 -0
- package/src/types/publishPipeline/displayNames.test.ts +5 -5
- package/src/types/publishPipeline/displayNames.ts +6 -6
- package/src/types/publishPipeline/index.ts +3 -3
- package/src/types/publishPipeline/plan.test.ts +12 -105
- package/src/types/publishPipeline/plan.ts +3 -62
- package/src/types/response/PostApplicationCloneResponseBody.ts +12 -0
- package/src/types/response/index.ts +1 -0
- package/src/types/reviewPolicy/index.ts +5 -0
- package/src/utils/attachment-upload.test.ts +70 -1
- package/src/utils/attachment-upload.ts +106 -0
- package/src/utils/git-url.test.ts +65 -0
- package/src/utils/git-url.ts +29 -6
- package/src/utils/normalizeRoleName.test.ts +27 -0
- package/src/utils/normalizeRoleName.ts +2 -2
|
@@ -291,6 +291,34 @@ function getTextLikeMimeTypeFromFileName(fileName: string | undefined): string |
|
|
|
291
291
|
return textAttachmentType ? TEXT_ATTACHMENT_MIME_TYPES[textAttachmentType] : undefined;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Names the media type for a file that arrives with nothing but a name, as it
|
|
296
|
+
* does when a caller points at a path instead of handing over the bytes.
|
|
297
|
+
*
|
|
298
|
+
* Derived from {@link MIME_TYPE_FILE_EXTENSIONS} rather than a general-purpose
|
|
299
|
+
* MIME database so the answer stays inside what attachments actually accept:
|
|
300
|
+
* a wider lookup would name a type for `.exe` that upload then refuses.
|
|
301
|
+
*/
|
|
302
|
+
export function getAttachmentMimeTypeFromFileName(fileName?: string): string | undefined {
|
|
303
|
+
const normalizedFileName = normalizeNonEmptyString(fileName)?.toLowerCase();
|
|
304
|
+
if (!normalizedFileName) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const [mimeType, extension] of Object.entries(MIME_TYPE_FILE_EXTENSIONS)) {
|
|
309
|
+
// '.jpeg' resolves through the text-like pass below only if listed there,
|
|
310
|
+
// so both spellings are matched here against the canonical extension.
|
|
311
|
+
if (normalizedFileName.endsWith(extension)) {
|
|
312
|
+
return mimeType === 'image/jpg' ? 'image/jpeg' : mimeType;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (normalizedFileName.endsWith('.jpeg')) {
|
|
316
|
+
return 'image/jpeg';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return getTextLikeMimeTypeFromFileName(normalizedFileName);
|
|
320
|
+
}
|
|
321
|
+
|
|
294
322
|
function isProviderSafeTextMimeType(mimeType: string): boolean {
|
|
295
323
|
return mimeType.startsWith('text/') || mimeType === 'application/json';
|
|
296
324
|
}
|
|
@@ -630,8 +658,86 @@ export async function uploadAttachmentToServerApi(params: {
|
|
|
630
658
|
|
|
631
659
|
if (!response.ok) {
|
|
632
660
|
const errorText = await response.text().catch(() => '');
|
|
661
|
+
const parsed = parseUserFacingUploadError(errorText);
|
|
662
|
+
if (parsed) {
|
|
663
|
+
throw new AttachmentUploadApiError(parsed.message, {
|
|
664
|
+
code: parsed.code,
|
|
665
|
+
status: response.status
|
|
666
|
+
});
|
|
667
|
+
}
|
|
633
668
|
throw new Error(`Attachment upload failed: status=${response.status}${errorText ? `, body=${errorText}` : ''}`);
|
|
634
669
|
}
|
|
635
670
|
|
|
636
671
|
return (await response.json()) as AttachmentUploadServerDto;
|
|
637
672
|
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* The upload route sets this code ONLY on rejections whose `{ error }` sentence
|
|
676
|
+
* is written for the end user and is safe to show verbatim.
|
|
677
|
+
*
|
|
678
|
+
* `{ error }` is otherwise the route's generic diagnostic channel. It carries
|
|
679
|
+
* internal 500/502 detail (app-scoped-token, invalid Bucketeer response, raw
|
|
680
|
+
* Bucketeer text). Promoting that to a user-facing sentence leaked upstream
|
|
681
|
+
* internals into the UI, so the verbatim path is gated on this code rather
|
|
682
|
+
* than on the presence of the field.
|
|
683
|
+
*/
|
|
684
|
+
export type UserFacingUploadErrorCode = 'attachment_content_mismatch';
|
|
685
|
+
|
|
686
|
+
export const ATTACHMENT_CONTENT_MISMATCH_CODE: UserFacingUploadErrorCode = 'attachment_content_mismatch';
|
|
687
|
+
|
|
688
|
+
export class AttachmentUploadApiError extends Error {
|
|
689
|
+
readonly code: UserFacingUploadErrorCode;
|
|
690
|
+
readonly status: number;
|
|
691
|
+
|
|
692
|
+
constructor(message: string, options: { code: UserFacingUploadErrorCode; status: number }) {
|
|
693
|
+
super(message);
|
|
694
|
+
this.name = 'AttachmentUploadApiError';
|
|
695
|
+
this.code = options.code;
|
|
696
|
+
this.status = options.status;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export function isUserFacingUploadError(error: unknown): error is AttachmentUploadApiError {
|
|
701
|
+
return error instanceof AttachmentUploadApiError;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function readStringProperty(value: object, key: string): string | undefined {
|
|
705
|
+
if (!(key in value)) {
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
const property = Reflect.get(value, key);
|
|
709
|
+
return typeof property === 'string' ? property : undefined;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function isUserFacingUploadErrorCode(code: string): code is UserFacingUploadErrorCode {
|
|
713
|
+
return code === ATTACHMENT_CONTENT_MISMATCH_CODE;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Pull the server's user-facing `{ code, error }` pair out of a response body.
|
|
718
|
+
* Returns undefined unless `code` is {@link ATTACHMENT_CONTENT_MISMATCH_CODE},
|
|
719
|
+
* and for non-JSON bodies, non-string `error` fields, and blank strings, so
|
|
720
|
+
* the caller keeps the diagnostic form that still carries the HTTP status.
|
|
721
|
+
*/
|
|
722
|
+
export function parseUserFacingUploadError(body: string): { code: UserFacingUploadErrorCode; message: string } | undefined {
|
|
723
|
+
if (!body.trim()) {
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
const parsed: unknown = JSON.parse(body);
|
|
728
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
const code = readStringProperty(parsed, 'code');
|
|
732
|
+
const message = readStringProperty(parsed, 'error');
|
|
733
|
+
if (code === undefined || !isUserFacingUploadErrorCode(code)) {
|
|
734
|
+
return undefined;
|
|
735
|
+
}
|
|
736
|
+
if (message === undefined || !message.trim()) {
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
return { code, message };
|
|
740
|
+
} catch {
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
@@ -21,6 +21,10 @@ describe('isValidGitUrl', () => {
|
|
|
21
21
|
it('trims whitespace', () => {
|
|
22
22
|
expect(isValidGitUrl(' https://github.com/org/repo ')).toBe(true);
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
it('accepts ssh:// double-slash absolute-path URLs (valid git SSH syntax)', () => {
|
|
26
|
+
expect(isValidGitUrl('ssh://git@host//org/repo.git')).toBe(true);
|
|
27
|
+
});
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
describe('isGitHubUrl', () => {
|
|
@@ -144,3 +148,64 @@ describe('sshGitUrlToHttps', () => {
|
|
|
144
148
|
expect(sshGitUrlToHttps('not-a-url')).toBeNull();
|
|
145
149
|
});
|
|
146
150
|
});
|
|
151
|
+
|
|
152
|
+
describe('git-url ReDoS (SEC-78)', () => {
|
|
153
|
+
it('still validates/normalizes real URLs', () => {
|
|
154
|
+
expect(isValidGitUrl('https://github.com/org/repo.git')).toBe(true);
|
|
155
|
+
expect(isValidGitUrl('git@github.com:org/repo.git')).toBe(true);
|
|
156
|
+
expect(isValidGitUrl('ssh://git@github.com/org/repo')).toBe(true);
|
|
157
|
+
expect(isValidGitUrl('not a url')).toBe(false);
|
|
158
|
+
expect(gitRemoteUrlToWeb('git@github.com:org/repo.git')).toBe('https://github.com/org/repo');
|
|
159
|
+
expect(sshGitUrlToHttps('git@github.com:org/repo.git')).toBe('https://github.com/org/repo.git');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('still converts SCP-style paths containing spaces (e.g. Azure DevOps repo names)', () => {
|
|
163
|
+
// Raw SCP `git@host:path` strings are not URL-encoded by convention, so
|
|
164
|
+
// legitimate paths (Azure DevOps project/repo names in particular) may
|
|
165
|
+
// contain literal spaces. The bounded regex must preserve this — only the
|
|
166
|
+
// length is capped, not the character class.
|
|
167
|
+
expect(gitRemoteUrlToWeb('git@host:org/repo name.git')).toBe('https://host/org/repo name');
|
|
168
|
+
expect(sshGitUrlToHttps('git@host:org/repo name.git')).toBe('https://host/org/repo name.git');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('does not rescan trailing whitespace polynomially on the SCP sshMatch fallback', () => {
|
|
172
|
+
// Internal-whitespace shape: a long run of spaces BEFORE the final
|
|
173
|
+
// non-whitespace char, rather than after it (the existing adversarial
|
|
174
|
+
// test puts the non-whitespace run first). Against the pre-fix
|
|
175
|
+
// `(?:\.git)?\s*$` tail this drove a genuine O(4096*n) scan (measured
|
|
176
|
+
// ~3.9s at n=1,000,000) because the lazy path capture and the trailing
|
|
177
|
+
// `\s*` were two independent backtracking axes. `trimmed` never ends in
|
|
178
|
+
// whitespace, so `\s*` before the final `$` was always redundant — it
|
|
179
|
+
// was dropped in favor of a plain `$`.
|
|
180
|
+
const evil = 'git@h:' + ' '.repeat(200000) + 'X';
|
|
181
|
+
const start = performance.now();
|
|
182
|
+
gitRemoteUrlToWeb(evil);
|
|
183
|
+
sshGitUrlToHttps(evil);
|
|
184
|
+
expect(performance.now() - start).toBeLessThan(100);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does not backtrack polynomially on adversarial inputs', () => {
|
|
188
|
+
const cases = [
|
|
189
|
+
'https://' + 'a.'.repeat(50000), // HTTPS_GIT_URL_RE host
|
|
190
|
+
'https://x/' + 'a/'.repeat(50000), // HTTPS_GIT_URL_RE path
|
|
191
|
+
'git@' + 'a.'.repeat(50000) + ':x', // SSH_GIT_URL_RE host
|
|
192
|
+
'ssh://git@h/' + 'a/'.repeat(50000), // SSH_PROTOCOL_RE path
|
|
193
|
+
'git@h:' + 'a'.repeat(200000), // SCP sshMatch (L72/226)
|
|
194
|
+
// Trailing-whitespace ambiguity against the `(?:\.git)?\s*$` tail: the
|
|
195
|
+
// lazy `.+?` capture and the `\s*` quantifier are two independent
|
|
196
|
+
// O(n) backtracking axes, which combine into genuine O(n^2) blowup on
|
|
197
|
+
// the pre-fix regex (confirmed: ~35ms at n=8000, ~120ms at n=16000 —
|
|
198
|
+
// consistent ~4x-per-2x quadratic scaling; would take tens of seconds
|
|
199
|
+
// at n=200000 without the bound).
|
|
200
|
+
'git@h:' + 'a'.repeat(16000) + ' '.repeat(16000) + 'X',
|
|
201
|
+
'git@ssh.dev.azure.com:v3/o/p/' + 'a'.repeat(16000) + ' '.repeat(16000) + 'X'
|
|
202
|
+
];
|
|
203
|
+
for (const evil of cases) {
|
|
204
|
+
const start = performance.now();
|
|
205
|
+
isValidGitUrl(evil);
|
|
206
|
+
gitRemoteUrlToWeb(evil);
|
|
207
|
+
sshGitUrlToHttps(evil);
|
|
208
|
+
expect(performance.now() - start).toBeLessThan(150);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
package/src/utils/git-url.ts
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* Used by both the UI and the vite-plugin-file-sync AI agent tooling.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// Character classes below are bounded ({1,256} / {1,4096}) to avoid polynomial
|
|
9
|
+
// backtracking on adversarial input (SEC-78) — real hosts/paths are far shorter,
|
|
10
|
+
// so behavior is preserved for every valid git remote URL.
|
|
11
|
+
const HTTPS_GIT_URL_RE = /^https?:\/\/[^\s/$.?#]{1,256}(?:\.[^\s/$.?#]{1,256})+\/[^\s/$.?#]{1,256}(?:\/[^\s/$.?#]{1,256})+(?:\.git)?\/?$/i;
|
|
12
|
+
const SSH_GIT_URL_RE = /^[\w-]{1,256}@[\w.-]{1,256}:[\w./-]{1,4096}(?:\.git)?$/;
|
|
10
13
|
// ssh:// protocol format: ssh://git@host/path
|
|
11
|
-
const SSH_PROTOCOL_RE = /^ssh:\/\/[\w-]
|
|
14
|
+
const SSH_PROTOCOL_RE = /^ssh:\/\/[\w-]{1,256}@[\w.-]{1,256}\/[\w./-]{1,4096}(?:\.git)?\/?$/i;
|
|
12
15
|
|
|
13
16
|
export function isValidGitUrl(url: string): boolean {
|
|
14
17
|
const trimmed = url.trim();
|
|
@@ -69,7 +72,15 @@ export function gitRemoteUrlToWeb(url: string): string | null {
|
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
// SCP-style SSH: git@host:org/repo.git → https://host/org/repo
|
|
72
|
-
|
|
75
|
+
// Path capture is bounded ({1,4096}) so it cannot drive polynomial
|
|
76
|
+
// backtracking on adversarial input (SEC-78). The char class itself is
|
|
77
|
+
// unchanged (`.`) — paths may legitimately contain spaces (e.g. Azure
|
|
78
|
+
// DevOps project/repo names), and the numeric bound alone is enough to
|
|
79
|
+
// keep matching linear. `trimmed` (see top of function) never ends in
|
|
80
|
+
// whitespace, so the trailing `\s*` that used to sit before `$` was
|
|
81
|
+
// redundant (always zero-width) and only added its own O(n) rescan per
|
|
82
|
+
// lazy-capture step — dropped for a plain `$`.
|
|
83
|
+
const sshMatch = trimmed.match(/^[\w-]{1,256}@([\w.-]{1,256}):(.{1,4096}?)(?:\.git)?$/);
|
|
73
84
|
if (sshMatch?.[1] && sshMatch[2]) {
|
|
74
85
|
const host = sshMatch[1];
|
|
75
86
|
const path = sshMatch[2];
|
|
@@ -212,7 +223,12 @@ export function sshGitUrlToHttps(url: string): string | null {
|
|
|
212
223
|
const host = parsed.hostname;
|
|
213
224
|
const path = parsed.pathname;
|
|
214
225
|
if (host === 'ssh.dev.azure.com') {
|
|
215
|
-
|
|
226
|
+
// Path capture is bounded ({1,4096}) so the trailing `(?:\.git)?\/?$`
|
|
227
|
+
// cannot drive polynomial backtracking on adversarial input (SEC-78).
|
|
228
|
+
// The char class itself is unchanged (`.`) — Azure DevOps repo names
|
|
229
|
+
// may legitimately contain spaces, and the numeric bound alone is
|
|
230
|
+
// enough to keep matching linear.
|
|
231
|
+
const azureMatch = path.match(/^\/v3\/([^/]{1,256})\/([^/]{1,256})\/(.{1,4096}?)(?:\.git)?\/?$/);
|
|
216
232
|
if (azureMatch) {
|
|
217
233
|
return `https://dev.azure.com/${azureMatch[1]}/${azureMatch[2]}/_git/${azureMatch[3]}`;
|
|
218
234
|
}
|
|
@@ -223,7 +239,14 @@ export function sshGitUrlToHttps(url: string): string | null {
|
|
|
223
239
|
// Not a standard URL — try SCP-style SSH below.
|
|
224
240
|
}
|
|
225
241
|
|
|
226
|
-
|
|
242
|
+
// Path capture is bounded ({1,4096}) so it cannot drive polynomial
|
|
243
|
+
// backtracking on adversarial input (SEC-78). The char class itself is
|
|
244
|
+
// unchanged (`.`) — paths may legitimately contain spaces, and the numeric
|
|
245
|
+
// bound alone is enough to keep matching linear. `trimmed` (see top of
|
|
246
|
+
// function) never ends in whitespace, so the trailing `\s*` that used to
|
|
247
|
+
// sit before `$` was redundant (always zero-width) and only added its own
|
|
248
|
+
// O(n) rescan per lazy-capture step — dropped for a plain `$`.
|
|
249
|
+
const sshMatch = trimmed.match(/^[\w-]{1,256}@([\w.-]{1,256}):(.{1,4096}?)(?:\.git)?$/);
|
|
227
250
|
if (sshMatch?.[1] && sshMatch[2]) {
|
|
228
251
|
const host = sshMatch[1];
|
|
229
252
|
const path = sshMatch[2];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { normalizeRoleName } from './normalizeRoleName.js';
|
|
4
|
+
|
|
5
|
+
describe('normalizeRoleName', () => {
|
|
6
|
+
it('lowercases a simple name', () => {
|
|
7
|
+
expect(normalizeRoleName('Admin')).toBe('admin');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('collapses internal non-alphanumeric runs to a single underscore', () => {
|
|
11
|
+
expect(normalizeRoleName('Team Lead')).toBe('team_lead');
|
|
12
|
+
expect(normalizeRoleName('a__b')).toBe('a_b');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('strips leading and trailing non-alphanumeric characters', () => {
|
|
16
|
+
expect(normalizeRoleName(' Team Lead ')).toBe('team_lead');
|
|
17
|
+
expect(normalizeRoleName('!!!Role@@@')).toBe('role');
|
|
18
|
+
expect(normalizeRoleName('___')).toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('handles a long non-alphanumeric run without catastrophic backtracking', () => {
|
|
22
|
+
const input = `${'!'.repeat(100_000)}role`;
|
|
23
|
+
const start = Date.now();
|
|
24
|
+
expect(normalizeRoleName(input)).toBe('role');
|
|
25
|
+
expect(Date.now() - start).toBeLessThan(1_000);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function normalizeRoleName(displayName: string): string {
|
|
2
2
|
const key = displayName
|
|
3
3
|
.trim() // remove leading/trailing whitespace
|
|
4
|
-
.replace(
|
|
5
|
-
.replace(
|
|
4
|
+
.replace(/[\W_]+/g, '_') // collapse non-alphanumeric runs to a single underscore (linear)
|
|
5
|
+
.replace(/^_|_$/g, ''); // strip the single edge underscore left by the collapse step (no quantifier -> no polynomial shape)
|
|
6
6
|
return key.toLowerCase();
|
|
7
7
|
}
|