Sql Constants!



  •  Came across this horror in the VB project we inherited:

        Public ADD_CONTRACT1 As String = "INSERT INTO Reimb_Contract (ContractCode, Title, Description, CurrentFY, VoucherType, Type)   VALUES ('{0}', '{1}', '{2}', {3}, {4}, {5});SELECT scope_identity();"
        Public ADD_CONTRACT2 As String = "INSERT INTO Reimb_ContractFYInfo (Contract_ID, FiscalYear_ID, SCID, FromDate, ToDate) VALUES({0}, {1}, '{2}', '{3}', '{4}');"
        Public SUM_TOTAL_BUDGET As String = "SELECT sum(TotalBudget) SUM_TotalBudget FROM Reimb_ContractBudgets where year = {0} and contract_id='{1}' AND CashInd ='{2}';"
        Public SUM_ORIG_HS_BUDGET As String = "SELECT Sum([OrigHSBudgetAmount]) SUM_OrigHSBudgetAmount FROM [Reimb_ContractBudgets] WHERE year = {0} AND contract_id={1} AND CashInd ='{2}';"
        Public SUM_AMEND_HS_BUDGET As String = "SELECT Sum([AmendHSBudgetAmount]) SUM_OrigHSBudgetAmount FROM [Reimb_ContractBudgets] WHERE year = {0} AND contract_id={1} AND CashInd ='{2}';"
        Public SUM_CCDBG_BUDGET_AMOUNT As String = "SELECT SUM(CCDBGBudgetAmount) FROM Reimb_BudgetIdentifier AS BI INNER JOIN Reimb_ContractBudgets AS CB ON BI.BudgetIdentifier_ID = CB.BudgetIdentifier_ID where Contract_ID={0} AND Fund_ID = 2 AND CashInd ='{1}' AND Year = {2}"

     

    There are more but jesus that's disgustingly unsafe. I'm horrified. The worst part is the developer clearly knew about sql to use scope_identity() yet not enough to avoid unsafe code like this.



  • I'm not entirely sure this is where the unsafe part is. Not saying it's great or anything, because it requires diligence on the part of any developer that will utilize those format strings, but the danger lies elsewhere.



  • @anachostic said:

    I'm not entirely sure this is where the unsafe part is. Not saying it's great or anything, because it requires diligence on the part of any developer that will utilize those format strings, but the danger lies elsewhere.

     

    Technically, by itself, that's not "unsafe". .NET will sanitize any input from a text box or other input mechanism before allowing you to use it unless you turn that off (which it was). It's really just bad practice and made me laugh when I saw it.

     


  • Trolleybus Mechanic

    @Darsithis said:

    .NET will sanitize any input from a text box or other input mechanism before allowing you to use it unless you turn that off
     

    lolwut?

    I mean... "I find your coding technique intriguing, and would love to see more of your work. Do you have a portfolio of high-valued sites you've coded for that I could perhaps peruse?"

    Seriously, though... the OP is right, that's some unsafe shit waiting to explode right there. It's too bad, because it's SOOOOO close to being right, and yet so very very wrong.

    Dim theFuckingQuery as String = "INSERT INTO the_fucking_table (fuck_id, fuck_value) VALUES (?, ?); SELECT scope_identity();"
    

    Dim params as SomeFuckingCollectionOfParameters = new SomeFuckingCollectionOfParameters()

    params.add(1, "First Fucking Value")

    dblayer.exec(theFuckingQuery, params) ' Safe!

    params.add(2, "Second Fucking Value'; DROP TABLE overused_xkcd_references--")

    dblayer.exec(theFuckingQuery, params) ' Safe!

    ' SAFE! Let's fuck it up:

    Dim myFuckingQuery as String = theFuckingQuery 'Automatic fail for naming a variable with "my"

    myFuckingQuery = myFuckingQuery.Replace("?", 1).Replace("?", "'" + "Fuck em up';-- DROP TABLE yougetthejoke;-- + "'")

    dblayer.exec(myFuckingQuery) ' DO YOU SEE? DO YOU SEE?



  • That's why I say it requires diligence. I certainly wouldn't code that way.  I don't even know how your sequenced Replaces would do what it seems you want them to.  I'd do something like:

    <font color="#657b83" face="Consolas" size="2"><font color="#657b83" face="Consolas" size="2"><font color="#657b83" face="Consolas" size="2">SqlHelper.ExecuteScaler(connectionString, CommandType.Text, String.Format(query,arg1.replace("'","''"),arg2.replace("'","''"),arg3.replace("'","''"))</font></font></font> 


  • Considered Harmful

    @anachostic said:

    That's why I say it requires diligence. I certainly wouldn't code that way.  I don't even know how your sequenced Replaces would do what it seems you want them to.  I'd do something like:

    <font color="#657b83" face="Consolas" size="2"><font color="#657b83" face="Consolas" size="2"><font color="#657b83" face="Consolas" size="2">SqlHelper.ExecuteScaler(connectionString, CommandType.Text, String.Format(query,arg1.replace("'","''"),arg2.replace("'","''"),arg3.replace("'","''"))</font></font></font> 

    No. No. No. No. No. Stop telling people this. It is wrong, and it is not safe.

    Notice that not all of those parameters are quoted (presumably they are integers or some such). This will not protect you from injection on those fields.



  • I'm a little nervous replying to the Global Moderator of MMO-Champion.com. I mean, how do I start? "Your Majesty?" "Your Excellence?" Man. (Clicks Link). Oh he plays a Death Knight. "Hey Dumbass," will suffice.



  • @anachostic said:

    That's why I say it requires diligence. I certainly wouldn't code that way. I don't even know how your sequenced Replaces would do what it seems you want them to. I'd do something like:

    <font color="#657b83" face="Consolas" size="2"><font color="#657b83" face="Consolas" size="2"><font color="#657b83" face="Consolas" size="2">SqlHelper.ExecuteScaler(connectionString, CommandType.Text, String.Format(query,arg1.replace("'","''"),arg2.replace("'","''"),arg3.replace("'","''"))</font></font></font>

    Congratulations, your site is pwned.

    Look, you're using .net. This problem is solved. Extremely thoroughly solved in .net in multiple ways no other languages/runtimes come close. In fact, it's kind of actually HARD to get it wrong. Even if you just ACCIDENTALLY used LINQ-To-SQL for convenience-sake, this would be fixed for you.

    BTW, hang around this forum for a few weeks, see how fucking awful the average software developer is, then tell me a solution requiring "diligence" is ok. It ain't. Developers suck shit. Let the machine do it.



  •  @joe.edwards said:

    No. No. No. No. No. Stop telling people this. It is wrong, and it is not safe.

    Notice that not all of those parameters are quoted (presumably they are integers or some such). This will not protect you from injection on those fields.

    Hold on now.  This is an example.  Give me a little credit here.

    I never gave the format string for "query", so you should presume that all the arguments are strings.  I considered making one of them an integer, in which case, I would not have .replace on it, since I always use Option Strict On.

    And agreeing with Blake, yes, programmers are dumb.  Would I prefer a DAC class with strong-typed input parameters and SQLParameters calling Stored Procs?  Duh.  That's not what this post is about, though.  This is probably a single-person project or a 2-person team that has the same coding style.

     



  • @anachostic said:

    This is probably a single-person project or a 2-person team that has the same coding style.

    So it's ok that it's wrong?



  •  @blakeyrat said:

    @anachostic said:
    This is probably a single-person project or a 2-person team that has the same coding style.

    So it's ok that it's wrong?

    No, but there's a better return on investment for ensuring that it is done correctly and safely than rewriting the whole thing.


  • ♿ (Parody)

    @anachostic said:

    And agreeing with Blake, yes, programmers are dumb.

    Only because they are people.



  • @anachostic said:

     @blakeyrat said:

    @anachostic said:
    This is probably a single-person project or a 2-person team that has the same coding style.

    So it's ok that it's wrong?

    No, but there's a better return on investment for ensuring that it is done correctly and safely than rewriting the whole thing.

    Am I missing something? Is this trolling, mis-understanding, or just dumbassery? Who's on first?

    They're calling you an idiot for not parameterizing your query. Generally speaking, if I see someone using String.Format and/or concatenation around SQL statements, not to mention attempting to sanitize the data themselves, I start reaching for the good old clue-by-four.

    Some recommended reading.


  • Trolleybus Mechanic

    @anachostic said:

    I never gave the format string for "query", so you should presume that all the arguments are strings.
     

    If you don't understand why this single line proves everything that everyone else is saying, then there is no hope for you.



  •  @mikeTheLiar said:

    @anachostic said:

     @blakeyrat said:

    @anachostic said:
    This is probably a single-person project or a 2-person team that has the same coding style.

    So it's ok that it's wrong?

    No, but there's a better return on investment for ensuring that it is done correctly and safely than rewriting the whole thing.

    Am I missing something? Is this trolling, mis-understanding, or just dumbassery? Who's on first?

    They're calling you an idiot for not parameterizing your query. Generally speaking, if I see someone using String.Format and/or concatenation around SQL statements, not to mention attempting to sanitize the data themselves, I start reaching for the good old clue-by-four.

    Some recommended reading.

     

    They are arguing the wrong point.  I've already said I don't do it that way.  But if I did do it that way, or if I was handed that project for maintenance, I would make sure that it is safe from SQL injection.  It's not my query.  It's a query in the format of the first post.

    I think the misunderstanding is that I did not say: "This is the only and best way to do data access!"  Instead, I suggested a way to at least mitigate the risk with the code already in place.  This isn't a boolean solution.  There's bad, better, and best ways to address this.  No one even wants to listen to the "better" option.

     



  • @anachostic said:

     @mikeTheLiar said:

    @anachostic said:

     @blakeyrat said:

    @anachostic said:
    This is probably a single-person project or a 2-person team that has the same coding style.

    So it's ok that it's wrong?

    No, but there's a better return on investment for ensuring that it is done correctly and safely than rewriting the whole thing.

    Am I missing something? Is this trolling, mis-understanding, or just dumbassery? Who's on first?

    They're calling you an idiot for not parameterizing your query. Generally speaking, if I see someone using String.Format and/or concatenation around SQL statements, not to mention attempting to sanitize the data themselves, I start reaching for the good old clue-by-four.

    Some recommended reading.

     

    They are arguing the wrong point.  I've already said I don't do it that way.  But if I did do it that way, or if I was handed that project for maintenance, I would make sure that it is safe from SQL injection.  It's not my query.  It's a query in the format of the first post.

    I think the misunderstanding is that I did not say: "This is the only and best way to do data access!"  Instead, I suggested a way to at least mitigate the risk with the code already in place.  This isn't a boolean solution.  There's bad, better, and best ways to address this.  No one even wants to listen to the "better" option.

     

    Because the "better" option still sucks. There's the right way, and then there's all the other ways. Which are wrong.


  • ♿ (Parody)

    @anachostic said:

    I think the misunderstanding is that I did not say: "This is the only and best way to do data access!"  Instead, I suggested a way to at least mitigate the risk with the code already in place.  This isn't a boolean solution.  There's bad, better, and best ways to address this.  No one even wants to listen to the "better" option.

    I am saddened. I had just come to the conclusion that you were just trolling us. Your solution isn't the better solution. The better solution is to start switching stuff over to using parameters. That's better because it's actually easier than whatever you were thinking and it would actually work, like, all the time.



  •  @Lorne Kates said:

    @anachostic said:

    I never gave the format string for "query", so you should presume that all the arguments are strings.
     

    If you don't understand why this single line proves everything that everyone else is saying, then there is no hope for you.

      Explain how this gets injected with Option Strict On:

    Sub Whatever(id As Integer)
        Dim sql As String = "select name from users where id={0}"
        exec(String.Format(sql, id))
    End Sub
    

     



  • @anachostic said:

     @Lorne Kates said:

    @anachostic said:

    I never gave the format string for "query", so you should presume that all the arguments are strings.
     

    If you don't understand why this single line proves everything that everyone else is saying, then there is no hope for you.

     
    Explain how this gets injected with Option Strict On:

    Sub Whatever(id As Integer)
        Dim sql As String = "select name from users where id={0}"
        exec(String.Format(sql, id))
    End Sub
    

     

    You're either deliberately missing the point for teh trolleriffic lulz or you're denser than a neutron star.



  • @anachostic said:

    Instead, I suggested a way to at least mitigate the risk with the code already in place.

    Can't be done.

    @anachostic said:

    This isn't a boolean solution.

    Yes it is. There are "wrong" solutions and there are "correct" solutions.

    @anachostic said:

    There's bad, better, and best ways to address this.

    No, there's only bad, bad, and barely acceptable. Nothing is best.

    @anachostic said:

    No one even wants to listen to the "better" option.

    Not when the "better" option is exactly as bad as the "bad" option.



  •  @boomzilla said:

    I am saddened. I had just come to the conclusion that you were just trolling us. Your solution isn't the better solution. The better solution is to start switching stuff over to using parameters. That's better because it's actually easier than whatever you were thinking and it would actually work, like, all the time.

    It is better than what they have (and I don't even know that because the code that executes the query wasn't provided).  Why can't both be done?  Mitigate the risk, then work on improving it?  Maybe this application is in maintenance and there's no need to improve it because its replacement is in development. 

     


  • ♿ (Parody)

    @anachostic said:

    It is better than what they have (and I don't even know that because the code that executes the query wasn't provided).  Why can't both be done?  Mitigate the risk, then work on improving it?  Maybe this application is in maintenance and there's no need to improve it because its replacement is in development. 

    You're wasting your time with your "mitigation," which should be spent simply doing the correct fix. For something like this, it's pretty trivial. Certainly more trivial than this mitigation you think you're doing. In this case, I would count risk mitigation as triage based on what's most likely to be vulnerable / broken.



  • @anachostic said:

     @Lorne Kates said:

    @anachostic said:

    I never gave the format string for "query", so you should presume that all the arguments are strings.
     

    If you don't understand why this single line proves everything that everyone else is saying, then there is no hope for you.

     
    Explain how this gets injected with Option Strict On:

    Sub Whatever(id As Integer)
        Dim sql As String = "select name from users where id={0}"
        exec(String.Format(sql, id))
    End Sub
    

     

    And now your query plan cache is probably massively bloated, and the CPU is getting fucked with compilations, because any DBA dumb enough not to put a stop to this coding style definitely won't have a clue about setting forced parameterization.


  • Discourse touched me in a no-no place

    @anachostic said:

    Hold on now. This is an example. Give me a little credit here.
    You spent your credit long ago, and are now overdrawn.

    Look, if you're doing work quoting values to put into SQL, or even if you're stupidly not bothering to quote them, then you're still fucking doing it wrong. Capiche? Even if we ignore the important issues relating to security, you're doing work which you shouldn't have to: using proper query parameters allows the DB engine to completely avoid having to put effort into comprehending the values at all during the compilation of the SQL statement, as it can just understand that there is a value there that you provide when you execute the statement. It's quicker like this. It's safer like this. It's clearer like this. It's easier like this. Doing stuff the bad old way in the face of such overwhelming reasons to do the right thing is not good programming at all; it's just stubborn stupidity.

    It's different if you've got to provide things like the names of tables or columns; those aren't SQL values, but rather are names. SQL's a fairly limited language after all…



  •  Perhaps they process and replace the "{..}" with parameter references....perhaps pigs can fly (oh wait)


  • Discourse touched me in a no-no place

    @TheCPUWizard said:

    Perhaps they process and replace the "{..}" with parameter references....perhaps pigs can fly (oh wait)
    Perhaps you'd like to express that statement in terms of a wager…?



  • @dkf said:

    @TheCPUWizard said:
    Perhaps they process and replace the "{..}" with parameter references....perhaps pigs can fly (oh wait)
    Perhaps you'd like to express that statement in terms of a wager…?

    WordPress used %s for parameters because PHP is a horrible language and they had to support embedded devices with no filesystem.



  • @dkf said:

    @TheCPUWizard said:
    Perhaps they process and replace the "{..}" with parameter references....perhaps pigs can fly (oh wait)
    Perhaps you'd like to express that statement in terms of a wager…?

    Sure - as long as it can be for a negative amount....this way you pay me when I "lose"!!! 



  • @TheCPUWizard said:

    @dkf said:

    @TheCPUWizard said:
    Perhaps they process and replace the "{..}" with parameter references....perhaps pigs can fly (oh wait)
    Perhaps you'd like to express that statement in terms of a wager…?

    Sure - as long as it can be for a negative amount....this way you pay me when I "lose"!!! 

    I'll bet you $NaN that I'll win this bet.



  • @dkf said:

    Doing stuff the bad old way in the face of such overwhelming reasons to do the right thing is not good programming at all; it's just stubborn stupidity.
    It's stubbidity!



  •  "Better is the enemy of good" - fortune cookie from 2 days ago, which is very apt in this case. Point being, sure you can make it "better" by doing whatever you want, but that's not a good way, and keeps you from doing it a good way, being happy that you made it better. The good way is trivially easy already.



  •  I got the impression from a few people that they thought I wrote this. I did not. I'm debating how to deal with it because this system IS in production (not development, no replacement, it is the replacement) and I have to be careful how many hours I devote to it and what I do. While I do stage all of my changes most people aren't going to pay for us to waste (to them) hours correcting what amounts to behind-the-scenes work that has no real impact for the users.

    Regardless of whether or not .NET sanitizes input it's still a terrible way to code.


  • Discourse touched me in a no-no place

    @Darsithis said:

    Regardless of whether or not .NET sanitizes input it's still a terrible way to code.
    Terrible? For sure. Worth replacing in this case? Highly likely; you've got both safety and performance reasons to want to make things better, and those are good things to make a case for acting on during the maintenance phase.



  • @Sutherlands said:

     "Better is the enemy of good" - fortune cookie from 2 days ago, which is very apt in this case. Point being, sure you can make it "better" by doing whatever you want, but that's not a good way, and keeps you from doing it a good way, being happy that you made it better. The good way is trivially easy already.

     

    Actually that is not what it mean.... If something is already "good" then there is often little benefit and a potentially high risk from trying to make it "better". Many people us a longer form of the quote "Better is the enemy of good enough". 

     



  • I always thought it was "perfection is the enemy of the complete".


  • Discourse touched me in a no-no place

    @mikeTheLiar said:

    I always thought it was "perfection is the enemy of the complete".
    And I've heard it as “the perfect is the enemy of the good”.


  • Trolleybus Mechanic

    I thought it was "The enemey of my enemy will be killed last."



  • @Lorne Kates said:

    I thought it was "The enemey of my enemy will be killed last."
     

    The enemy of my enemy is my enemy's enemy, no more, no less.  Maxim #29

     



  • @Mason Wheeler said:

    @Lorne Kates said:

    I thought it was "The enemey of my enemy will be killed last."
     

    The enemy of my enemy is my enemy's enemy, no more, no less.  Maxim #29

     

    The enemy of my masochistic enemy is my enemy.



  • @Ben L. said:

    @Mason Wheeler said:

    @Lorne Kates said:

    I thought it was "The enemey of my enemy will be killed last."
     

    The enemy of my enemy is my enemy's enemy, no more, no less.  Maxim #29

     

    The enemy of my masochistic enemy is my enemy.

    The enema of my enemy is an anemone



  • @db2 said:

    @anachostic said:

     @Lorne Kates said:


    Explain how this gets injected with Option Strict On:

    Sub Whatever(id As Integer)
        Dim sql As String = "select name from users where id={0}"
        exec(String.Format(sql, id))
    End Sub
    

    And now your query plan cache is probably massively bloated...

    This has not been a problem with query plan cache since SQL 2005 which introduced auto-paramaterization.   The query engine tries to autoparamterize all non-parameterized queries.  Watch a trace sometime and look at how it hits the cache.  It looks for one with a parameter and one without every time, and it saves off the paramterized version.

     Granted, the paremterized version saves it the trouble and might actually run faster, but it is pretty much FUD that simple queries like this will bloat the query plan cache.  (One should always just paramterize queries to be consistant and avoid human mistakes)



  • @Darsithis said:

     I got the impression from a few people that they thought I wrote this. I did not. I'm debating how to deal with it because this system IS in production (not development, no replacement, it is the replacement) and I have to be careful how many hours I devote to it and what I do. While I do stage all of my changes most people aren't going to pay for us to waste (to them) hours correcting what amounts to behind-the-scenes work that has no real impact for the users.

    Regardless of whether or not .NET sanitizes input it's still a terrible way to code.

    If those are in a common location as it appears, it is going to take a lot of work to switch to parametrized queries, unless by some miracle all of the use of these is handled by a central function in the application instead of just using String.Format everywhere.

    Your quick bet would be to use some data-type specific formatters in each of those strings, such as {0:D}, forcing it to a numeric value, etc.  It will force String.Format to do a little bit more data type checking, etc throughout the application where the string is used.

     If it is a common function that gets passed a param(), you could alter it to generate params by value instead of just using String.Format.  That should be minimal code and offers a great deal of improvement in safety.



  •  @TheCPUWizard said:

    @Sutherlands said:

     "Better is the enemy of good" - fortune cookie from 2 days ago, which is very apt in this case. Point being, sure you can make it "better" by doing whatever you want, but that's not a good way, and keeps you from doing it a good way, being happy that you made it better. The good way is trivially easy already.

     

    Actually that is not what it mean.... If something is already "good" then there is often little benefit and a potentially high risk from trying to make it "better". Many people us a longer form of the quote "Better is the enemy of good enough". 

     

    The quote you're referring to is "Perfect is the enemy of good," which doesn't even necessarily mean what you're saying.  But that's the crazy thing about words, you change them around and get different meanings. 

     



  • @Sutherlands said:

    The quote you're referring to is "Perfect is the enemy of good,"

    The people who couldn't remember the exact quote shouldn't have bothered posting anything.



  • @The Bytemaster said:

    @db2 said:
    @anachostic said:

     @Lorne Kates said:

    Explain how this gets injected with Option Strict On:

    Sub Whatever(id As Integer)
        Dim sql As String = "select name from users where id={0}"
        exec(String.Format(sql, id))
    End Sub
    

    And now your query plan cache is probably massively bloated...

    This has not been a problem with query plan cache since SQL 2005 which introduced auto-paramaterization.   The query engine tries to autoparamterize all non-parameterized queries.  Watch a trace sometime and look at how it hits the cache.  It looks for one with a parameter and one without every time, and it saves off the paramterized version.

     Granted, the paremterized version saves it the trouble and might actually run faster, but it is pretty much FUD that simple queries like this will bloat the query plan cache.  (One should always just paramterize queries to be consistant and avoid human mistakes)

     

    Each part of your comment is correct but I'd like to add another reason to specifically parameterise.

     

    SELECT * from BLAH

    where TheDate>'1/1/2014'

    and Status = 'Y'

    Gets auto parameterised to 

    SELECT * from BLAH

    where TheDate>?

    and Status = ?

     

    Which is nice, but

    SELECT * from BLAH

    where TheDate>?

    and Status = 'Y'

    and

    SELECT * from BLAH

    where TheDate>?

    and Status = 'N'

    may well be best served by two very different plans depending on the selectivity of Status. Turning on AP takes that away. It also cannot guess the maximum length of a string which can also have side effects.

     

     

     


  • Discourse touched me in a no-no place

    @LoztInSpace said:

    Turning on AP takes that away.
    It also doesn't actually protect against attacks (because quoting arbitrary strings in SQL is annoyingly tricky and the DB engine can't help as things are already too confused at that point). Even better is named parameterization where those names are bound to the in-scope names at the point of evaluation of the SQL (well, from a client perspective of course). That's tricky to do from a client library perspective usually, but makes for an awesomely easy integration for the programmer-user of the client library.



  • @Ben L. said:

    WordPress used %s for parameters because PHP is a horrible language...

    That has nothing to do with PHP and everything to do with WP being a flaming turd.



  • @dkf said:

    @LoztInSpace said:
    Turning on AP takes that away.
    It also doesn't actually protect against attacks (because quoting arbitrary strings in SQL is annoyingly tricky and the DB engine can't help as things are already too confused at that point). Even better is named parameterization where those names are bound to the in-scope names at the point of evaluation of the SQL (well, from a client perspective of course). That's tricky to do from a client library perspective usually, but makes for an awesomely easy integration for the programmer-user of the client library.
     

     

    Yes, true, though I wasn't trying to say I think AP is ok or that there are not many good ways of parameterising queries.  I was agreeing that having AP on can reduce or eliminate cache bloat.  However the main point I was trying to make was that

    a) there are cases when you actually want a known, constant value in your query because certain, specific values will significantly influence what plan will be the best

    b) AP removes any advantages gained by using said constant even if it does give you some "advantages" in other areas (i.e. you can be a shit programmer and inject values into strings to make SQL).

     


  • Discourse touched me in a no-no place

    @LoztInSpace said:

    there are cases when you actually want a known, constant value in your query because certain, specific values will significantly influence what plan will be the best
    Oh sure, and those are cases where you probably don't want the application user specifying the value anyway.



  •  @RTapeLoadingError said:

    @Sutherlands said:
    The quote you're referring to is "Perfect is the enemy of good,"

    The people who couldn't remember the exact quote shouldn't have bothered posting anything.

     

    Well mine was from a fortune cookie, and was an exact quote of that cookie, so I'm good then.

Log in to reply